Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37012da26a | ||
|
|
0fd6a8c31d | ||
|
|
10af904681 | ||
|
|
b02b83f225 | ||
|
|
bca5d63a4e | ||
|
|
67c5571df5 | ||
|
|
ea5320d94a | ||
|
|
ffd3111b71 | ||
|
|
b0949f1a1e | ||
|
|
84416d04bf |
19
App.tsx
19
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';
|
||||
@@ -300,6 +301,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
keysRef.current = keys;
|
||||
const knownHostsRef = useRef(knownHosts);
|
||||
knownHostsRef.current = knownHosts;
|
||||
// Bridge the gap while useVaultState hydrates: its async init awaits
|
||||
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
|
||||
// so the state is briefly [] at boot even when localStorage has entries.
|
||||
// Any SSH connect during that window (manual click or restored session)
|
||||
// would otherwise see no trusted hosts and prompt for fingerprint
|
||||
// re-confirmation. Mirrors the same fallback already used by sync payloads.
|
||||
const effectiveKnownHosts = useMemo(
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
|
||||
const {
|
||||
sessions,
|
||||
@@ -1996,7 +2007,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
customGroups={customGroups}
|
||||
knownHosts={knownHosts}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
shellHistory={shellHistory}
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
@@ -2069,7 +2080,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippetPackages={snippetPackages}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={knownHosts}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
draggingSessionId={draggingSessionId}
|
||||
terminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
@@ -2426,7 +2437,9 @@ function AppWithProviders() {
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<ToastProvider>
|
||||
<App settings={settings} />
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<App settings={settings} />
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -358,12 +358,16 @@ const en: Messages = {
|
||||
'settings.terminal.scrollback.rows': 'Number of rows *',
|
||||
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
|
||||
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': 'Reset built-ins to defaults',
|
||||
'settings.terminal.keywordHighlight.resetBuiltIn': 'Restore default label and patterns',
|
||||
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
|
||||
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
|
||||
'settings.terminal.keywordHighlight.editBuiltIn': 'Edit Built-in Rule',
|
||||
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': 'Regex Pattern',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': 'Regex (e.g., \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternField': 'Regex Patterns',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': 'One regex per line (e.g., \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternHint': 'One regex per line. Patterns are matched case-insensitively with the global flag.',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'settings.terminal.keywordHighlight.preview': 'Preview',
|
||||
'settings.terminal.section.localShell': 'Local Shell',
|
||||
@@ -2054,6 +2058,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;
|
||||
|
||||
@@ -1490,12 +1490,16 @@ const zhCN: Messages = {
|
||||
'settings.terminal.scrollback.rows': '行数 *',
|
||||
'settings.terminal.keywordHighlight.title': '关键字高亮',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': '把内置规则恢复为默认',
|
||||
'settings.terminal.keywordHighlight.resetBuiltIn': '恢复内置标签与正则',
|
||||
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
|
||||
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
|
||||
'settings.terminal.keywordHighlight.editBuiltIn': '编辑内置规则',
|
||||
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': '正则表达式',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': '正则表达式(如 \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': '每行一个正则(如 \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternHint': '每行一个正则。匹配忽略大小写,全局匹配。',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
|
||||
'settings.terminal.keywordHighlight.preview': '预览',
|
||||
'settings.terminal.section.localShell': '本地 Shell',
|
||||
@@ -2063,6 +2067,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;
|
||||
|
||||
@@ -150,6 +150,10 @@ export const useSftpBackend = () => {
|
||||
return bridge.getHomeDir();
|
||||
}, []);
|
||||
|
||||
const listDrives = useCallback(async () => {
|
||||
return await netcattyBridge.get()?.listDrives?.() ?? [];
|
||||
}, []);
|
||||
|
||||
const startStreamTransfer = useCallback(
|
||||
async (
|
||||
options: Parameters<NonNullable<NetcattyBridge["startStreamTransfer"]>>[0],
|
||||
@@ -268,6 +272,7 @@ export const useSftpBackend = () => {
|
||||
mkdirLocal,
|
||||
statLocal,
|
||||
getHomeDir,
|
||||
listDrives,
|
||||
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
|
||||
@@ -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";
|
||||
@@ -133,6 +134,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
} = useSftpBackend();
|
||||
|
||||
const sftpRef = useRef(sftp);
|
||||
@@ -296,6 +298,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -651,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>
|
||||
)}
|
||||
|
||||
@@ -136,6 +136,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
} = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
@@ -262,6 +263,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
const visibleTransfers = useMemo(
|
||||
|
||||
@@ -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";
|
||||
@@ -1506,9 +1507,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
onHasSelectionChange: setHasSelection,
|
||||
disableBracketedPasteRef,
|
||||
scrollOnPasteRef,
|
||||
});
|
||||
// Kept fresh on every render so the mouseTracking capture handler at
|
||||
@@ -1879,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 side="bottom">{t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{/* Server Stats Display */}
|
||||
@@ -1904,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>
|
||||
@@ -1973,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>
|
||||
@@ -1995,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) */}
|
||||
@@ -2008,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) */}
|
||||
@@ -2016,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>
|
||||
@@ -2050,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>
|
||||
@@ -2083,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>
|
||||
@@ -2099,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(
|
||||
@@ -2127,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"
|
||||
@@ -2161,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>
|
||||
@@ -2205,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 side="bottom">
|
||||
{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 side="bottom">{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,74 @@ 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>
|
||||
<div className="self-stretch flex items-center px-2 app-drag" style={dragRegionStyle}>
|
||||
<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={onOpenSettings}
|
||||
>
|
||||
<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
|
||||
@@ -2229,6 +2233,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<ContextMenuItem onClick={() => handleEditHost(host)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
|
||||
<Pin className="mr-2 h-4 w-4" /> {t('vault.hosts.unpin')}
|
||||
</ContextMenuItem>
|
||||
@@ -2328,6 +2338,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<ContextMenuItem onClick={() => handleEditHost(host)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
|
||||
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -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,8 @@ 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 { Textarea } from "../../ui/textarea";
|
||||
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";
|
||||
@@ -33,21 +35,25 @@ const AddCustomRuleDialog: React.FC<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editRule?: KeywordHighlightRule | null;
|
||||
isBuiltIn?: boolean;
|
||||
onAdd: (rule: KeywordHighlightRule) => void;
|
||||
}> = ({ open, onOpenChange, editRule, onAdd }) => {
|
||||
}> = ({ open, onOpenChange, editRule, isBuiltIn = false, onAdd }) => {
|
||||
const { t } = useI18n();
|
||||
const [label, setLabel] = useState('');
|
||||
const [pattern, setPattern] = useState('');
|
||||
// Multi-line text: one regex pattern per line. Built-in rules typically
|
||||
// ship multiple patterns (e.g. several spellings of "error"), and the user
|
||||
// is allowed to add as many as they like.
|
||||
const [patternsText, setPatternsText] = useState('');
|
||||
const [color, setColor] = useState(DEFAULT_NEW_RULE_COLOR);
|
||||
const [patternError, setPatternError] = useState<string | null>(null);
|
||||
|
||||
const reset = () => { setLabel(''); setPattern(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
|
||||
const reset = () => { setLabel(''); setPatternsText(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (open && editRule) {
|
||||
setLabel(editRule.label);
|
||||
setPattern(editRule.patterns[0] || '');
|
||||
setPatternsText(editRule.patterns.join('\n'));
|
||||
setColor(editRule.color);
|
||||
setPatternError(null);
|
||||
} else if (!open) {
|
||||
@@ -56,25 +62,43 @@ const AddCustomRuleDialog: React.FC<{
|
||||
}, [open, editRule]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!label.trim() || !pattern.trim()) return;
|
||||
try { new RegExp(pattern, 'gi'); } catch {
|
||||
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
|
||||
return;
|
||||
if (!label.trim()) return;
|
||||
const patterns = patternsText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
if (patterns.length === 0) return;
|
||||
for (const p of patterns) {
|
||||
try { new RegExp(p, 'gi'); } catch {
|
||||
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// When editing, replace only the first pattern and keep any additional ones
|
||||
const patterns = editRule
|
||||
? [pattern, ...editRule.patterns.slice(1)]
|
||||
: [pattern];
|
||||
onAdd({ id: editRule?.id ?? crypto.randomUUID(), label: label.trim(), patterns, color, enabled: editRule?.enabled ?? true });
|
||||
onAdd({
|
||||
id: editRule?.id ?? crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
patterns,
|
||||
color,
|
||||
enabled: editRule?.enabled ?? true,
|
||||
// Editing a built-in rule flips it into "user-customized" mode so the
|
||||
// normalizer keeps the user's patterns across restarts.
|
||||
customized: isBuiltIn ? true : editRule?.customized,
|
||||
});
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const dialogTitleKey = editRule
|
||||
? (isBuiltIn
|
||||
? 'settings.terminal.keywordHighlight.editBuiltIn'
|
||||
: 'settings.terminal.keywordHighlight.editCustom')
|
||||
: 'settings.terminal.keywordHighlight.addCustom';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editRule ? t('settings.terminal.keywordHighlight.editCustom') : t('settings.terminal.keywordHighlight.addCustom')}</DialogTitle>
|
||||
<DialogTitle>{t(dialogTitleKey)}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
@@ -94,16 +118,19 @@ const AddCustomRuleDialog: React.FC<{
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('settings.terminal.keywordHighlight.patternField')}</Label>
|
||||
<Input
|
||||
<Textarea
|
||||
placeholder={t('settings.terminal.keywordHighlight.patternPlaceholder')}
|
||||
value={pattern}
|
||||
onChange={(e) => { setPattern(e.target.value); if (patternError) setPatternError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
className={cn("font-mono", patternError && "border-destructive")}
|
||||
value={patternsText}
|
||||
onChange={(e) => { setPatternsText(e.target.value); if (patternError) setPatternError(null); }}
|
||||
rows={Math.max(3, Math.min(10, patternsText.split('\n').length + 1))}
|
||||
className={cn("font-mono text-xs", patternError && "border-destructive")}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('settings.terminal.keywordHighlight.patternHint')}
|
||||
</p>
|
||||
{patternError && <div className="text-xs text-destructive">{patternError}</div>}
|
||||
</div>
|
||||
{label.trim() && pattern.trim() && !patternError && (
|
||||
{label.trim() && patternsText.trim() && !patternError && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">{t('settings.terminal.keywordHighlight.preview')}:</span>
|
||||
<span className="text-sm font-medium" style={{ color }}>{label}</span>
|
||||
@@ -112,7 +139,7 @@ const AddCustomRuleDialog: React.FC<{
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!label.trim() || !pattern.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!label.trim() || !patternsText.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -132,26 +159,43 @@ const KeywordHighlightRulesEditor: React.FC<{
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{rules.map((rule) => {
|
||||
const custom = !isBuiltIn(rule.id);
|
||||
const builtIn = isBuiltIn(rule.id);
|
||||
const customized = builtIn && rule.customized;
|
||||
return (
|
||||
<div key={rule.id} className="flex items-center gap-2 group">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span className={cn("text-sm truncate", !rule.enabled && "text-muted-foreground line-through")} style={rule.enabled ? { color: rule.color } : undefined}>
|
||||
{rule.label}
|
||||
</span>
|
||||
{custom && (
|
||||
<>
|
||||
<Pencil
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
|
||||
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
|
||||
/>
|
||||
<Trash2
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
|
||||
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
|
||||
/>
|
||||
</>
|
||||
<Pencil
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
|
||||
/>
|
||||
{!builtIn && (
|
||||
<Trash2
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-destructive"
|
||||
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
|
||||
/>
|
||||
)}
|
||||
{customized && (
|
||||
<RotateCcw
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
aria-label={t('settings.terminal.keywordHighlight.resetBuiltIn')}
|
||||
onClick={() => {
|
||||
// Drop the user's customizations and restore the shipped
|
||||
// defaults for label/patterns. Color stays whatever the
|
||||
// user picked (color is the only built-in property they
|
||||
// can edit without flipping `customized`).
|
||||
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
|
||||
if (!def) return;
|
||||
onChange(rules.map((r) => r.id === rule.id
|
||||
? { ...def, color: r.color, enabled: r.enabled, customized: false }
|
||||
: r));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="relative flex-shrink-0">
|
||||
@@ -185,14 +229,18 @@ const KeywordHighlightRulesEditor: React.FC<{
|
||||
size="sm"
|
||||
className="flex-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
// Restore every built-in rule back to shipped defaults
|
||||
// (label/patterns/color), drop customizations, and keep the user's
|
||||
// custom rules untouched.
|
||||
onChange(rules.map((rule) => {
|
||||
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
|
||||
return def ? { ...rule, color: def.color } : rule;
|
||||
if (!def) return rule;
|
||||
return { ...def, enabled: rule.enabled, customized: false };
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t("settings.terminal.keywordHighlight.resetColors")}
|
||||
{t("settings.terminal.keywordHighlight.resetDefaults")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -200,6 +248,7 @@ const KeywordHighlightRulesEditor: React.FC<{
|
||||
open={addDialogOpen}
|
||||
onOpenChange={(v) => { setAddDialogOpen(v); if (!v) setEditingRule(null); }}
|
||||
editRule={editingRule}
|
||||
isBuiltIn={editingRule ? isBuiltIn(editingRule.id) : false}
|
||||
onAdd={(rule) => {
|
||||
if (editingRule) {
|
||||
onChange(rules.map((r) => r.id === editingRule.id ? rule : r));
|
||||
@@ -960,35 +1009,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>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
* SFTP Breadcrumb navigation component
|
||||
*/
|
||||
|
||||
import { ChevronRight, Home, MoreHorizontal } from 'lucide-react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
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 {
|
||||
@@ -13,16 +15,31 @@ interface SftpBreadcrumbProps {
|
||||
onHome: () => void;
|
||||
/** Maximum number of visible path segments before truncation (default: 4) */
|
||||
maxVisibleParts?: number;
|
||||
isLocal?: boolean;
|
||||
onListDrives?: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
|
||||
path,
|
||||
onNavigate,
|
||||
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
|
||||
path,
|
||||
onNavigate,
|
||||
onHome,
|
||||
maxVisibleParts = 4
|
||||
maxVisibleParts = 4,
|
||||
isLocal,
|
||||
onListDrives,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const [drives, setDrives] = useState<string[]>([]);
|
||||
const [driveDropdownOpen, setDriveDropdownOpen] = useState(false);
|
||||
|
||||
const handleDriveDropdownOpen = useCallback(async (open: boolean) => {
|
||||
setDriveDropdownOpen(open);
|
||||
if (open && onListDrives) {
|
||||
const result = await onListDrives();
|
||||
setDrives(result);
|
||||
}
|
||||
}, [onListDrives]);
|
||||
|
||||
// Handle both Windows (C:\path) and Unix (/path) style paths
|
||||
const isWindowsPath = /^[A-Za-z]:/.test(path);
|
||||
const separator = isWindowsPath ? /[\\/]/ : /\//;
|
||||
@@ -70,52 +87,93 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
|
||||
};
|
||||
}, [parts, maxVisibleParts]);
|
||||
|
||||
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" />
|
||||
</>
|
||||
)}
|
||||
<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}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
{!isLast && <ChevronRight size={12} className="opacity-40 shrink-0" />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden cursor-default">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onHome}
|
||||
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
|
||||
>
|
||||
<Home size={12} />
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface SftpPaneCallbacks {
|
||||
// External folder upload from native directory picker.
|
||||
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
|
||||
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
|
||||
onListDrives: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface SftpDragCallbacks {
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -55,6 +55,7 @@ interface SftpPaneToolbarProps {
|
||||
onGoToTerminalCwd?: () => void;
|
||||
viewMode: 'list' | 'tree';
|
||||
onSetViewMode: (mode: 'list' | 'tree') => void;
|
||||
onListDrives?: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
// Prioritize breadcrumb path display. 6 action buttons need ~156px,
|
||||
@@ -105,6 +106,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
onGoToTerminalCwd,
|
||||
viewMode,
|
||||
onSetViewMode,
|
||||
onListDrives,
|
||||
}) => {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -475,20 +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)
|
||||
}
|
||||
/>
|
||||
</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 */}
|
||||
@@ -551,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"
|
||||
|
||||
@@ -512,6 +512,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onGoToTerminalCwd={onGoToTerminalCwd}
|
||||
viewMode={viewMode}
|
||||
onSetViewMode={handleSetViewMode}
|
||||
onListDrives={callbacks.onListDrives}
|
||||
/>
|
||||
|
||||
{treeEverMounted && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -48,6 +48,7 @@ interface UseSftpViewPaneCallbacksParams {
|
||||
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
|
||||
mkdirLocal?: (path: string) => Promise<void>;
|
||||
deleteLocalFile?: (path: string) => Promise<void>;
|
||||
listDrives: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
export const useSftpViewPaneCallbacks = ({
|
||||
@@ -63,6 +64,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
listLocalFiles,
|
||||
listDrives,
|
||||
}: UseSftpViewPaneCallbacksParams) => {
|
||||
const paneActions = useSftpViewPaneActions({ sftpRef });
|
||||
const fileOps = useSftpViewFileOps({
|
||||
@@ -174,6 +176,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
|
||||
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
|
||||
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
|
||||
onListDrives: listDrives,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -214,6 +217,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
|
||||
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
|
||||
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
|
||||
onListDrives: listDrives,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,26 +2,18 @@ import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { pasteTextIntoTerminal } from "../runtime/terminalUserPaste";
|
||||
import { clearTerminalViewport } from "../clearTerminalViewport";
|
||||
|
||||
type TerminalBackendWriteApi = {
|
||||
writeToSession: (sessionId: string, data: string) => void;
|
||||
};
|
||||
|
||||
export const useTerminalContextActions = ({
|
||||
termRef,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
onHasSelectionChange,
|
||||
disableBracketedPasteRef,
|
||||
scrollOnPasteRef,
|
||||
}: {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionRef: RefObject<string | null>;
|
||||
terminalBackend: TerminalBackendWriteApi;
|
||||
onHasSelectionChange?: (hasSelection: boolean) => void;
|
||||
disableBracketedPasteRef?: RefObject<boolean>;
|
||||
scrollOnPasteRef?: RefObject<boolean>;
|
||||
}) => {
|
||||
const onCopy = useCallback(() => {
|
||||
@@ -39,40 +31,24 @@ export const useTerminalContextActions = ({
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && sessionRef.current) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
|
||||
terminalBackend.writeToSession(sessionRef.current, data);
|
||||
if (scrollOnPasteRef?.current) {
|
||||
term.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
pasteTextIntoTerminal(term, text, {
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to paste from clipboard", err);
|
||||
}
|
||||
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
|
||||
}, [sessionRef, termRef, scrollOnPasteRef]);
|
||||
|
||||
const onPasteSelection = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const selection = term.getSelection();
|
||||
if (!selection || !sessionRef.current) return;
|
||||
let data = normalizeLineEndings(selection);
|
||||
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
|
||||
terminalBackend.writeToSession(sessionRef.current, data);
|
||||
if (scrollOnPasteRef?.current) {
|
||||
term.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
|
||||
pasteTextIntoTerminal(term, selection, {
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
});
|
||||
}, [sessionRef, termRef, scrollOnPasteRef]);
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
resolveTelnetPort,
|
||||
resolveTelnetUsername,
|
||||
} from "../../../domain/host";
|
||||
import {
|
||||
clearPasteResidualAfterTerminalWrite,
|
||||
prepareTerminalDataForUserPasteDisplay,
|
||||
} from "./terminalUserPaste";
|
||||
|
||||
/**
|
||||
* Per-connection token for stale-timer detection. The renderer reuses the
|
||||
@@ -206,13 +210,17 @@ const writeSessionData = (
|
||||
term: XTerm,
|
||||
data: string,
|
||||
) => {
|
||||
const displayData = prepareTerminalDataForUserPasteDisplay(term, data);
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
if (!shouldScrollOnTerminalOutput(settings)) {
|
||||
term.write(data);
|
||||
term.write(displayData, () => {
|
||||
clearPasteResidualAfterTerminalWrite(term);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
term.write(data, () => {
|
||||
term.write(displayData, () => {
|
||||
clearPasteResidualAfterTerminalWrite(term);
|
||||
handleTerminalOutputAutoScroll(ctx, term);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -43,6 +43,11 @@ import {
|
||||
} from "./kittyKeyboardProtocol";
|
||||
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
pasteTextIntoTerminal,
|
||||
shouldSuppressTerminalInputScrollForUserPaste,
|
||||
} from "./terminalUserPaste";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -405,19 +410,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const appLevelActions = getAppLevelActions();
|
||||
const terminalActions = getTerminalPassthroughActions();
|
||||
const scrollViewportToBottom = () => {
|
||||
term.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
};
|
||||
const scrollToBottomAfterPaste = () => {
|
||||
if (shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current)) {
|
||||
scrollViewportToBottom();
|
||||
}
|
||||
};
|
||||
const scrollToBottomAfterInput = (data: string) => {
|
||||
if (shouldScrollOnTerminalInput(ctx.terminalSettingsRef.current, data)) {
|
||||
term.scrollToBottom();
|
||||
@@ -542,15 +534,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
const rawData = normalizeLineEndings(text);
|
||||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||||
? wrapBracketedPaste(rawData)
|
||||
: rawData;
|
||||
// Notify autocomplete with the final bytes so bracketed
|
||||
// pastes preserve their inner newlines as literal input.
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterPaste();
|
||||
pasteTextIntoTerminal(term, text, {
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
@@ -559,13 +545,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const selection = term.getSelection();
|
||||
const id = ctx.sessionRef.current;
|
||||
if (selection && id) {
|
||||
const rawData = normalizeLineEndings(selection);
|
||||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||||
? wrapBracketedPaste(rawData)
|
||||
: rawData;
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterPaste();
|
||||
pasteTextIntoTerminal(term, selection, {
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -615,13 +597,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && ctx.sessionRef.current) {
|
||||
const rawData = normalizeLineEndings(text);
|
||||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||||
? wrapBracketedPaste(rawData)
|
||||
: rawData;
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
|
||||
scrollToBottomAfterPaste();
|
||||
pasteTextIntoTerminal(term, text, {
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[Terminal] Failed to paste from clipboard:", err);
|
||||
@@ -641,45 +619,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (id) {
|
||||
// Serial line mode: buffer input and send on Enter
|
||||
if (ctx.host.protocol === "serial" && ctx.serialLineMode && ctx.serialLineBufferRef) {
|
||||
if (data === "\r") {
|
||||
// Enter key: send buffered line + CR
|
||||
const line = ctx.serialLineBufferRef.current + "\r";
|
||||
ctx.terminalBackend.writeToSession(id, line);
|
||||
ctx.serialLineBufferRef.current = "";
|
||||
// Local echo newline if enabled
|
||||
if (ctx.serialLocalEcho) {
|
||||
term.write("\r\n");
|
||||
}
|
||||
} else if (data === "\x7f" || data === "\b") {
|
||||
// Backspace: remove last character from buffer
|
||||
if (ctx.serialLineBufferRef.current.length > 0) {
|
||||
ctx.serialLineBufferRef.current = ctx.serialLineBufferRef.current.slice(0, -1);
|
||||
if (ctx.serialLocalEcho) {
|
||||
term.write("\b \b");
|
||||
}
|
||||
}
|
||||
} else if (data === "\x03") {
|
||||
// Ctrl+C: clear buffer and send Ctrl+C
|
||||
ctx.serialLineBufferRef.current = "";
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
if (ctx.serialLocalEcho) {
|
||||
term.write("^C\r\n");
|
||||
}
|
||||
} else if (data === "\x15") {
|
||||
// Ctrl+U: clear line buffer
|
||||
if (ctx.serialLocalEcho && ctx.serialLineBufferRef.current.length > 0) {
|
||||
// Erase the displayed line
|
||||
const len = ctx.serialLineBufferRef.current.length;
|
||||
term.write("\b \b".repeat(len));
|
||||
}
|
||||
ctx.serialLineBufferRef.current = "";
|
||||
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
|
||||
// Regular characters: add to buffer
|
||||
ctx.serialLineBufferRef.current += data;
|
||||
if (ctx.serialLocalEcho) {
|
||||
term.write(data);
|
||||
}
|
||||
}
|
||||
handleSerialLineModeInput(data, {
|
||||
bufferRef: ctx.serialLineBufferRef,
|
||||
localEcho: ctx.serialLocalEcho,
|
||||
writeToSession: (nextData) => ctx.terminalBackend.writeToSession(id, nextData),
|
||||
writeToTerminal: (nextData) => term.write(nextData),
|
||||
});
|
||||
} else {
|
||||
// Character mode (default): send immediately
|
||||
// When backspaceBehavior is configured, remap the Backspace key output
|
||||
@@ -709,7 +654,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.onBroadcastInputRef.current(broadcastData, ctx.sessionId);
|
||||
}
|
||||
|
||||
scrollToBottomAfterInput(data);
|
||||
if (!shouldSuppressTerminalInputScrollForUserPaste(term, data)) {
|
||||
scrollToBottomAfterInput(data);
|
||||
}
|
||||
|
||||
// Notify autocomplete of input
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
|
||||
37
components/terminal/runtime/serialLineInput.test.ts
Normal file
37
components/terminal/runtime/serialLineInput.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
|
||||
test("serial line mode sends completed lines from a multi-line paste chunk", () => {
|
||||
const writes: string[] = [];
|
||||
const echoes: string[] = [];
|
||||
const bufferRef = { current: "" };
|
||||
|
||||
handleSerialLineModeInput("show version\rshow clock", {
|
||||
bufferRef,
|
||||
writeToSession: (data) => writes.push(data),
|
||||
writeToTerminal: (data) => echoes.push(data),
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, ["show version\r"]);
|
||||
assert.equal(bufferRef.current, "show clock");
|
||||
assert.deepEqual(echoes, []);
|
||||
});
|
||||
|
||||
test("serial line mode sends every completed line when pasted text ends with enter", () => {
|
||||
const writes: string[] = [];
|
||||
const echoes: string[] = [];
|
||||
const bufferRef = { current: "" };
|
||||
|
||||
handleSerialLineModeInput("show version\rshow clock\r", {
|
||||
bufferRef,
|
||||
localEcho: true,
|
||||
writeToSession: (data) => writes.push(data),
|
||||
writeToTerminal: (data) => echoes.push(data),
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, ["show version\r", "show clock\r"]);
|
||||
assert.equal(bufferRef.current, "");
|
||||
assert.deepEqual(echoes, ["show version", "\r\n", "show clock", "\r\n"]);
|
||||
});
|
||||
86
components/terminal/runtime/serialLineInput.ts
Normal file
86
components/terminal/runtime/serialLineInput.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
type StringRef = {
|
||||
current: string;
|
||||
};
|
||||
|
||||
type SerialLineModeInputOptions = {
|
||||
bufferRef: StringRef;
|
||||
localEcho?: boolean;
|
||||
writeToSession: (data: string) => void;
|
||||
writeToTerminal: (data: string) => void;
|
||||
};
|
||||
|
||||
const submitLine = ({
|
||||
bufferRef,
|
||||
localEcho,
|
||||
writeToSession,
|
||||
writeToTerminal,
|
||||
}: SerialLineModeInputOptions) => {
|
||||
const line = `${bufferRef.current}\r`;
|
||||
writeToSession(line);
|
||||
bufferRef.current = "";
|
||||
if (localEcho) writeToTerminal("\r\n");
|
||||
};
|
||||
|
||||
const appendText = (
|
||||
text: string,
|
||||
{ bufferRef, localEcho, writeToTerminal }: SerialLineModeInputOptions,
|
||||
) => {
|
||||
if (!text) return;
|
||||
bufferRef.current += text;
|
||||
if (localEcho) writeToTerminal(text);
|
||||
};
|
||||
|
||||
const clearLine = ({
|
||||
bufferRef,
|
||||
localEcho,
|
||||
writeToTerminal,
|
||||
}: SerialLineModeInputOptions) => {
|
||||
if (localEcho && bufferRef.current.length > 0) {
|
||||
writeToTerminal("\b \b".repeat(bufferRef.current.length));
|
||||
}
|
||||
bufferRef.current = "";
|
||||
};
|
||||
|
||||
export function handleSerialLineModeInput(
|
||||
data: string,
|
||||
options: SerialLineModeInputOptions,
|
||||
): void {
|
||||
if (data === "\r" || data === "\n") {
|
||||
submitLine(options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "\x7f" || data === "\b") {
|
||||
if (options.bufferRef.current.length > 0) {
|
||||
options.bufferRef.current = options.bufferRef.current.slice(0, -1);
|
||||
if (options.localEcho) options.writeToTerminal("\b \b");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "\x03") {
|
||||
options.bufferRef.current = "";
|
||||
options.writeToSession(data);
|
||||
if (options.localEcho) options.writeToTerminal("^C\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "\x15") {
|
||||
clearLine(options);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedData = data.replace(/\r\n/g, "\r").replace(/\n/g, "\r");
|
||||
if (normalizedData.includes("\r")) {
|
||||
const parts = normalizedData.split("\r");
|
||||
parts.forEach((part, index) => {
|
||||
appendText(part, options);
|
||||
if (index < parts.length - 1) submitLine(options);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.charCodeAt(0) >= 32 || data.length > 1) {
|
||||
appendText(data, options);
|
||||
}
|
||||
}
|
||||
230
components/terminal/runtime/terminalUserPaste.test.ts
Normal file
230
components/terminal/runtime/terminalUserPaste.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
clearPasteResidualAfterTerminalWrite,
|
||||
pasteTextIntoTerminal,
|
||||
prepareTerminalDataForUserPasteDisplay,
|
||||
shouldSuppressTerminalInputScrollForUserPaste,
|
||||
} from "./terminalUserPaste";
|
||||
|
||||
test("user paste delegates raw clipboard text to xterm paste handling", () => {
|
||||
const pasted: string[] = [];
|
||||
const term = {
|
||||
paste: (text: string) => pasted.push(text),
|
||||
scrollToBottom: () => {
|
||||
throw new Error("scrollToBottom should not run when scrollOnPaste is false");
|
||||
},
|
||||
};
|
||||
|
||||
const text = "line one\r\nline two\nline three";
|
||||
|
||||
pasteTextIntoTerminal(term, text, { scrollOnPaste: false });
|
||||
|
||||
assert.deepEqual(pasted, [text]);
|
||||
});
|
||||
|
||||
test("user paste preserves the existing scroll-on-paste behavior", () => {
|
||||
const calls: string[] = [];
|
||||
const term = {
|
||||
paste: () => calls.push("paste"),
|
||||
scrollToBottom: () => calls.push("scroll"),
|
||||
};
|
||||
|
||||
pasteTextIntoTerminal(term, "echo ok", {
|
||||
scrollOnPaste: true,
|
||||
requestAnimationFrame: (callback) => {
|
||||
calls.push("raf");
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ["paste", "scroll", "raf", "scroll"]);
|
||||
});
|
||||
|
||||
test("user paste with scroll disabled suppresses input auto-scroll for raw paste data", () => {
|
||||
const term = {
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
};
|
||||
|
||||
pasteTextIntoTerminal(term, "line one\nline two", {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
|
||||
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "line one\rline two"), true);
|
||||
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "x"), false);
|
||||
});
|
||||
|
||||
test("user paste with scroll disabled suppresses input auto-scroll for bracketed paste data", () => {
|
||||
const term = {
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
};
|
||||
|
||||
pasteTextIntoTerminal(term, "line one\nline two", {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
shouldSuppressTerminalInputScrollForUserPaste(term, "\x1b[200~line one\rline two\x1b[201~"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("user paste with scroll enabled keeps input auto-scroll available", () => {
|
||||
const term = {
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
};
|
||||
|
||||
pasteTextIntoTerminal(term, "line one\nline two", {
|
||||
scrollOnPaste: true,
|
||||
requestAnimationFrame: () => {},
|
||||
});
|
||||
|
||||
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "line one\rline two"), false);
|
||||
});
|
||||
|
||||
test("user paste with scroll disabled suppresses split input chunks", () => {
|
||||
const term = {
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
};
|
||||
|
||||
pasteTextIntoTerminal(term, "line one\nline two", {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
|
||||
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "line one\r"), true);
|
||||
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "line two"), true);
|
||||
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "line two"), false);
|
||||
});
|
||||
|
||||
test("long multi-line paste strips readline active-region highlighting from echo", () => {
|
||||
const term = {
|
||||
cols: 20,
|
||||
rows: 4,
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
write: () => {},
|
||||
};
|
||||
|
||||
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
|
||||
pasteTextIntoTerminal(term, longPaste, {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7mline 3 with enough content\x1b[27m"),
|
||||
"line 3 with enough content",
|
||||
);
|
||||
});
|
||||
|
||||
test("long multi-line paste preserves unrelated reverse-video output", () => {
|
||||
const term = {
|
||||
cols: 20,
|
||||
rows: 4,
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
write: () => {},
|
||||
};
|
||||
|
||||
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
|
||||
pasteTextIntoTerminal(term, longPaste, {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7munrelated ncurses status\x1b[27m"),
|
||||
"\x1b[7munrelated ncurses status\x1b[27m",
|
||||
);
|
||||
});
|
||||
|
||||
test("long multi-line paste strips only matched paste echo segments in mixed output", () => {
|
||||
const term = {
|
||||
cols: 20,
|
||||
rows: 4,
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
write: () => {},
|
||||
};
|
||||
|
||||
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
|
||||
pasteTextIntoTerminal(term, longPaste, {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForUserPasteDisplay(
|
||||
term,
|
||||
"mode \x1b[7mINSERT\x1b[27m \x1b[7mline 3 with enough content\x1b[27m done",
|
||||
),
|
||||
"mode \x1b[7mINSERT\x1b[27m line 3 with enough content done",
|
||||
);
|
||||
});
|
||||
|
||||
test("long multi-line paste strips matched paste echo when active-region spans chunks", () => {
|
||||
const term = {
|
||||
cols: 20,
|
||||
rows: 4,
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
write: () => {},
|
||||
};
|
||||
|
||||
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
|
||||
pasteTextIntoTerminal(term, longPaste, {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7mline 3 with enough"),
|
||||
"line 3 with enough",
|
||||
);
|
||||
assert.equal(
|
||||
prepareTerminalDataForUserPasteDisplay(term, " content\x1b[27m"),
|
||||
" content",
|
||||
);
|
||||
});
|
||||
|
||||
test("long multi-line paste does not clear cursor-right residue before terminal echo", () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
cols: 20,
|
||||
rows: 4,
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
write: (data: string) => writes.push(data),
|
||||
};
|
||||
|
||||
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
|
||||
pasteTextIntoTerminal(term, longPaste, {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
|
||||
clearPasteResidualAfterTerminalWrite(term);
|
||||
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("long multi-line paste clears cursor-right residue after terminal echo", () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
cols: 20,
|
||||
rows: 4,
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
write: (data: string) => writes.push(data),
|
||||
};
|
||||
|
||||
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
|
||||
pasteTextIntoTerminal(term, longPaste, {
|
||||
scrollOnPaste: false,
|
||||
});
|
||||
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7mline 3 with enough content\x1b[27m");
|
||||
|
||||
clearPasteResidualAfterTerminalWrite(term);
|
||||
|
||||
assert.deepEqual(writes, ["\x1b[K"]);
|
||||
});
|
||||
315
components/terminal/runtime/terminalUserPaste.ts
Normal file
315
components/terminal/runtime/terminalUserPaste.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
type PasteTarget = Pick<XTerm, "paste" | "scrollToBottom"> &
|
||||
Partial<Pick<XTerm, "cols" | "rows" | "write">>;
|
||||
|
||||
type PasteOptions = {
|
||||
scrollOnPaste?: boolean;
|
||||
requestAnimationFrame?: (callback: () => void) => unknown;
|
||||
};
|
||||
|
||||
type PasteDisplayState = {
|
||||
expiresAt: number;
|
||||
clearPending: number;
|
||||
pasteEchoFragments: string[];
|
||||
inPasteEchoActiveRegion: boolean;
|
||||
};
|
||||
|
||||
type PasteInputScrollState = {
|
||||
expiresAt: number;
|
||||
remainingDataVariants: string[];
|
||||
};
|
||||
|
||||
const pasteDisplayStates = new WeakMap<object, PasteDisplayState>();
|
||||
const pasteInputScrollStates = new WeakMap<object, PasteInputScrollState>();
|
||||
const LONG_PASTE_MIN_LENGTH = 200;
|
||||
const PASTE_DISPLAY_FIX_WINDOW_MS = 4000;
|
||||
const PASTE_INPUT_SCROLL_WINDOW_MS = 4000;
|
||||
const READLINE_ACTIVE_REGION_START = "\x1b[7m";
|
||||
const READLINE_ACTIVE_REGION_END = "\x1b[27m";
|
||||
const BRACKETED_PASTE_START = "\x1b[200~";
|
||||
const BRACKETED_PASTE_END = "\x1b[201~";
|
||||
const MIN_PASTE_ECHO_FRAGMENT_LENGTH = 6;
|
||||
const ESC = "\x1b";
|
||||
const BEL = "\x07";
|
||||
|
||||
const getNow = () => Date.now();
|
||||
|
||||
const isStateActive = <T extends { expiresAt: number }>(state: T | undefined): state is T =>
|
||||
!!state && state.expiresAt > getNow();
|
||||
|
||||
const stripReadlineActiveRegion = (data: string): string =>
|
||||
data
|
||||
.split(READLINE_ACTIVE_REGION_START)
|
||||
.join("")
|
||||
.split(READLINE_ACTIVE_REGION_END)
|
||||
.join("");
|
||||
|
||||
const isCsiFinalByte = (char: string): boolean => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 0x40 && code <= 0x7e;
|
||||
};
|
||||
|
||||
const stripAnsiEscapeSequences = (data: string): string => {
|
||||
let plainText = "";
|
||||
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const char = data[index];
|
||||
if (char !== ESC) {
|
||||
plainText += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChar = data[index + 1];
|
||||
if (nextChar === "[") {
|
||||
index += 2;
|
||||
while (index < data.length && !isCsiFinalByte(data[index])) {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextChar === "]") {
|
||||
index += 2;
|
||||
while (index < data.length) {
|
||||
if (data[index] === BEL) break;
|
||||
if (data[index] === ESC && data[index + 1] === "\\") {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextChar) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return plainText;
|
||||
};
|
||||
|
||||
const stripNonLineBreakControls = (data: string): string => {
|
||||
let plainText = "";
|
||||
for (const char of data) {
|
||||
const code = char.charCodeAt(0);
|
||||
if (char === "\n" || (code >= 0x20 && code !== 0x7f)) {
|
||||
plainText += char;
|
||||
}
|
||||
}
|
||||
return plainText;
|
||||
};
|
||||
|
||||
const getPlainTerminalText = (data: string): string =>
|
||||
stripNonLineBreakControls(
|
||||
stripAnsiEscapeSequences(data).replace(/\r\n/g, "\n").replace(/\r/g, "\n"),
|
||||
);
|
||||
|
||||
const getPasteEchoFragments = (text: string): string[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
getPlainTerminalText(text)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length >= MIN_PASTE_ECHO_FRAGMENT_LENGTH),
|
||||
),
|
||||
);
|
||||
|
||||
const preparePasteTextForXterm = (text: string): string => text.replace(/\r?\n/g, "\r");
|
||||
|
||||
const getPasteInputDataVariants = (text: string): string[] => {
|
||||
const preparedText = preparePasteTextForXterm(text);
|
||||
return Array.from(
|
||||
new Set([
|
||||
preparedText,
|
||||
`${BRACKETED_PASTE_START}${preparedText}${BRACKETED_PASTE_END}`,
|
||||
]),
|
||||
).filter((candidate) => candidate.length > 0);
|
||||
};
|
||||
|
||||
const isExpectedPasteEcho = (data: string, state: PasteDisplayState): boolean => {
|
||||
if (state.pasteEchoFragments.length === 0) return false;
|
||||
|
||||
const candidateLines = getPlainTerminalText(stripReadlineActiveRegion(data))
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length >= MIN_PASTE_ECHO_FRAGMENT_LENGTH);
|
||||
|
||||
return candidateLines.some((line) =>
|
||||
state.pasteEchoFragments.some((fragment) => fragment.includes(line) || line.includes(fragment)),
|
||||
);
|
||||
};
|
||||
|
||||
const stripMatchedReadlineActiveRegion = (
|
||||
data: string,
|
||||
state: PasteDisplayState,
|
||||
): { data: string; matched: boolean } => {
|
||||
let index = 0;
|
||||
let matched = false;
|
||||
let nextData = "";
|
||||
|
||||
while (index < data.length) {
|
||||
if (state.inPasteEchoActiveRegion) {
|
||||
const endIndex = data.indexOf(READLINE_ACTIVE_REGION_END, index);
|
||||
if (endIndex === -1) {
|
||||
nextData += data.slice(index);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
|
||||
nextData += data.slice(index, endIndex);
|
||||
index = endIndex + READLINE_ACTIVE_REGION_END.length;
|
||||
state.inPasteEchoActiveRegion = false;
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const startIndex = data.indexOf(READLINE_ACTIVE_REGION_START, index);
|
||||
if (startIndex === -1) {
|
||||
nextData += data.slice(index);
|
||||
break;
|
||||
}
|
||||
|
||||
nextData += data.slice(index, startIndex);
|
||||
const contentStart = startIndex + READLINE_ACTIVE_REGION_START.length;
|
||||
const endIndex = data.indexOf(READLINE_ACTIVE_REGION_END, contentStart);
|
||||
|
||||
if (endIndex === -1) {
|
||||
const highlightedContent = data.slice(contentStart);
|
||||
if (isExpectedPasteEcho(highlightedContent, state)) {
|
||||
nextData += highlightedContent;
|
||||
state.inPasteEchoActiveRegion = true;
|
||||
matched = true;
|
||||
} else {
|
||||
nextData += data.slice(startIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const highlightedContent = data.slice(contentStart, endIndex);
|
||||
if (isExpectedPasteEcho(highlightedContent, state)) {
|
||||
nextData += highlightedContent;
|
||||
matched = true;
|
||||
} else {
|
||||
nextData += data.slice(startIndex, endIndex + READLINE_ACTIVE_REGION_END.length);
|
||||
}
|
||||
|
||||
index = endIndex + READLINE_ACTIVE_REGION_END.length;
|
||||
}
|
||||
|
||||
return { data: nextData, matched };
|
||||
};
|
||||
|
||||
const estimateRows = (text: string, cols: number): number => {
|
||||
const width = Math.max(1, cols);
|
||||
return text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.split("\n")
|
||||
.reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / width)), 0);
|
||||
};
|
||||
|
||||
const shouldApplyPasteDisplayFix = (term: PasteTarget, text: string): boolean => {
|
||||
if (text.length < LONG_PASTE_MIN_LENGTH) return false;
|
||||
|
||||
const lineCount = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").length;
|
||||
const rows = typeof term.rows === "number" && term.rows > 0 ? term.rows : 24;
|
||||
const cols = typeof term.cols === "number" && term.cols > 0 ? term.cols : 80;
|
||||
|
||||
return lineCount >= rows - 1 || estimateRows(text, cols) >= rows - 1;
|
||||
};
|
||||
|
||||
export function pasteTextIntoTerminal(
|
||||
term: PasteTarget,
|
||||
text: string,
|
||||
options: PasteOptions = {},
|
||||
): void {
|
||||
if (!text) return;
|
||||
|
||||
if (shouldApplyPasteDisplayFix(term, text)) {
|
||||
pasteDisplayStates.set(term, {
|
||||
expiresAt: getNow() + PASTE_DISPLAY_FIX_WINDOW_MS,
|
||||
clearPending: 0,
|
||||
pasteEchoFragments: getPasteEchoFragments(text),
|
||||
inPasteEchoActiveRegion: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.scrollOnPaste === false) {
|
||||
pasteInputScrollStates.set(term, {
|
||||
expiresAt: getNow() + PASTE_INPUT_SCROLL_WINDOW_MS,
|
||||
remainingDataVariants: getPasteInputDataVariants(text),
|
||||
});
|
||||
} else {
|
||||
pasteInputScrollStates.delete(term);
|
||||
}
|
||||
|
||||
term.paste(text);
|
||||
|
||||
if (!options.scrollOnPaste) return;
|
||||
|
||||
term.scrollToBottom();
|
||||
const scheduleFrame =
|
||||
options.requestAnimationFrame ??
|
||||
(typeof globalThis.requestAnimationFrame === "function"
|
||||
? globalThis.requestAnimationFrame.bind(globalThis)
|
||||
: undefined);
|
||||
|
||||
if (scheduleFrame) {
|
||||
scheduleFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldSuppressTerminalInputScrollForUserPaste(term: object, data: string): boolean {
|
||||
const state = pasteInputScrollStates.get(term);
|
||||
if (!isStateActive(state)) {
|
||||
pasteInputScrollStates.delete(term);
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchingIndex = state.remainingDataVariants.findIndex((candidate) => {
|
||||
if (candidate === data) return true;
|
||||
return candidate.startsWith(data);
|
||||
});
|
||||
if (matchingIndex === -1) return false;
|
||||
|
||||
const candidate = state.remainingDataVariants[matchingIndex];
|
||||
if (candidate.length > data.length) {
|
||||
state.remainingDataVariants[matchingIndex] = candidate.slice(data.length);
|
||||
} else {
|
||||
pasteInputScrollStates.delete(term);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function prepareTerminalDataForUserPasteDisplay(term: object, data: string): string {
|
||||
const state = pasteDisplayStates.get(term);
|
||||
if (!isStateActive(state)) return data;
|
||||
|
||||
const strippedActiveRegion = stripMatchedReadlineActiveRegion(data, state);
|
||||
if (strippedActiveRegion.matched) {
|
||||
state.clearPending = Math.max(state.clearPending, 3);
|
||||
return strippedActiveRegion.data;
|
||||
}
|
||||
|
||||
const isPasteEcho = isExpectedPasteEcho(data, state);
|
||||
if (isPasteEcho && (data.length > LONG_PASTE_MIN_LENGTH || data.includes("\r"))) {
|
||||
state.clearPending = Math.max(state.clearPending, 1);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function clearPasteResidualAfterTerminalWrite(term: object): void {
|
||||
const state = pasteDisplayStates.get(term);
|
||||
if (!isStateActive(state)) return;
|
||||
if (state.clearPending <= 0) return;
|
||||
if (typeof (term as Partial<Pick<XTerm, "write">>).write !== "function") return;
|
||||
|
||||
// Readline can leave stale cells to the right of the cursor after very long
|
||||
// bracketed paste redraws; clear them locally without sending bytes upstream.
|
||||
state.clearPending -= 1;
|
||||
(term as Pick<XTerm, "write">).write("\x1b[K");
|
||||
}
|
||||
151
domain/keywordHighlight.test.ts
Normal file
151
domain/keywordHighlight.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_KEYWORD_HIGHLIGHT_RULES,
|
||||
KeywordHighlightRule,
|
||||
normalizeTerminalSettings,
|
||||
} from "./models";
|
||||
|
||||
const IP_MAC_RULE = "ip-mac";
|
||||
|
||||
const ipMacDefault = () => {
|
||||
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === IP_MAC_RULE);
|
||||
if (!def) throw new Error("ip-mac default rule missing");
|
||||
return def;
|
||||
};
|
||||
|
||||
const getRule = (
|
||||
rules: KeywordHighlightRule[],
|
||||
id: string,
|
||||
): KeywordHighlightRule => {
|
||||
const rule = rules.find((r) => r.id === id);
|
||||
if (!rule) throw new Error(`rule ${id} missing`);
|
||||
return rule;
|
||||
};
|
||||
|
||||
const matchesAny = (patterns: string[], input: string): boolean =>
|
||||
patterns.some((p) => new RegExp(p, "gi").test(input));
|
||||
|
||||
test("ip-mac built-in rule includes IPv6 patterns by default", () => {
|
||||
const def = ipMacDefault();
|
||||
// Compressed mid-form (issue #958 example #1)
|
||||
assert.ok(
|
||||
matchesAny(def.patterns, "2001:11:22:33::5"),
|
||||
"expected default ip-mac rule to match 2001:11:22:33::5",
|
||||
);
|
||||
// Link-local compressed (issue #958 example #2)
|
||||
assert.ok(
|
||||
matchesAny(def.patterns, "fe80::d2dd:bff:fe79:f2bb"),
|
||||
"expected default ip-mac rule to match fe80::d2dd:bff:fe79:f2bb",
|
||||
);
|
||||
// Loopback
|
||||
assert.ok(matchesAny(def.patterns, "::1"), "expected ::1 to match");
|
||||
// Full form
|
||||
assert.ok(
|
||||
matchesAny(def.patterns, "2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
|
||||
"expected full-form IPv6 to match",
|
||||
);
|
||||
});
|
||||
|
||||
test("ip-mac IPv6 regex still matches IPv4 and MAC", () => {
|
||||
const def = ipMacDefault();
|
||||
assert.ok(matchesAny(def.patterns, "10.0.0.1"), "expected IPv4 still matches");
|
||||
assert.ok(
|
||||
matchesAny(def.patterns, "aa:bb:cc:dd:ee:ff"),
|
||||
"expected MAC still matches",
|
||||
);
|
||||
});
|
||||
|
||||
test("ip-mac IPv6 regex does not match obviously-not-IPv6 hex blobs", () => {
|
||||
const def = ipMacDefault();
|
||||
// A single hex word without colons must not match
|
||||
assert.ok(!matchesAny(def.patterns, "deadbeef"), "single hex word matched");
|
||||
// A typical sha-like string with colons separating fewer than two groups
|
||||
assert.ok(!matchesAny(def.patterns, "abc"), "stray hex matched");
|
||||
});
|
||||
|
||||
test("normalize adds newly-shipped default rules to legacy saved sets", () => {
|
||||
// Simulate an older save that only has 'error' and an old-shape 'ip-mac'
|
||||
// (i.e. without IPv6). Because the rule is NOT marked customized, normalize
|
||||
// should re-sync it with the latest shipped patterns.
|
||||
const legacyIpMacPatterns = ["legacy-pattern-from-old-default"];
|
||||
const saved: KeywordHighlightRule[] = [
|
||||
{
|
||||
id: "error",
|
||||
label: "Error",
|
||||
patterns: ["\\berror\\b"],
|
||||
color: "#F87171",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: IP_MAC_RULE,
|
||||
label: "URL, IP & MAC",
|
||||
patterns: legacyIpMacPatterns,
|
||||
color: "#EC4899",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const settings = normalizeTerminalSettings({
|
||||
keywordHighlightRules: saved,
|
||||
});
|
||||
const rules = settings.keywordHighlightRules;
|
||||
|
||||
// Every shipped default exists (warning/ok/info/debug get added).
|
||||
for (const def of DEFAULT_KEYWORD_HIGHLIGHT_RULES) {
|
||||
assert.ok(
|
||||
rules.some((r) => r.id === def.id),
|
||||
`expected normalize to include shipped rule ${def.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ip-mac was not customized → patterns re-sync to defaults, picking up IPv6.
|
||||
const ipMac = getRule(rules, IP_MAC_RULE);
|
||||
assert.deepEqual(ipMac.patterns, ipMacDefault().patterns);
|
||||
assert.ok(matchesAny(ipMac.patterns, "2001:11:22:33::5"));
|
||||
});
|
||||
|
||||
test("normalize preserves user-edited patterns when rule.customized is set", () => {
|
||||
const customPatterns = ["\\bMY_CUSTOM\\b", "\\bANOTHER\\b"];
|
||||
const customLabel = "My Errors";
|
||||
const saved: KeywordHighlightRule[] = [
|
||||
{
|
||||
id: "error",
|
||||
label: customLabel,
|
||||
patterns: customPatterns,
|
||||
color: "#FF0000",
|
||||
enabled: false,
|
||||
customized: true,
|
||||
},
|
||||
];
|
||||
|
||||
const settings = normalizeTerminalSettings({
|
||||
keywordHighlightRules: saved,
|
||||
});
|
||||
const rule = getRule(settings.keywordHighlightRules, "error");
|
||||
|
||||
assert.equal(rule.label, customLabel);
|
||||
assert.deepEqual(rule.patterns, customPatterns);
|
||||
assert.equal(rule.color, "#FF0000");
|
||||
assert.equal(rule.enabled, false);
|
||||
assert.equal(rule.customized, true);
|
||||
});
|
||||
|
||||
test("normalize keeps custom (non-built-in) rules verbatim", () => {
|
||||
const customRule: KeywordHighlightRule = {
|
||||
id: "user-uuid-1",
|
||||
label: "Pager",
|
||||
patterns: ["\\b[A-Z]{3}-\\d+\\b"],
|
||||
color: "#00FF00",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const settings = normalizeTerminalSettings({
|
||||
keywordHighlightRules: [customRule],
|
||||
});
|
||||
|
||||
const rule = getRule(settings.keywordHighlightRules, "user-uuid-1");
|
||||
assert.deepEqual(rule.patterns, customRule.patterns);
|
||||
assert.equal(rule.label, customRule.label);
|
||||
});
|
||||
@@ -464,6 +464,11 @@ export interface KeywordHighlightRule {
|
||||
patterns: string[]; // Regex patterns to match
|
||||
color: string; // Highlight color (hex)
|
||||
enabled: boolean;
|
||||
// Set to true when the user edits a built-in rule's label/patterns so
|
||||
// normalize keeps the user-edited values instead of overwriting them with
|
||||
// the latest shipped defaults. Absent / false means "still tracking defaults"
|
||||
// and the rule picks up new built-in patterns added in later versions.
|
||||
customized?: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalSettings {
|
||||
@@ -560,6 +565,24 @@ const URL_HIGHLIGHT_PATTERN =
|
||||
"(?:\\bhttps?:\\/\\/\\[[0-9A-Fa-f:.]+\\](?::\\d+)?(?:[/?#][^\\s<>\"'`]*)?(?<![.,;:!?\\)}])|\\b(?:https?:\\/\\/|www\\.)[^\\s<>\"'`]+(?<![.,;:!?\\])}]))";
|
||||
const IPV4_HIGHLIGHT_PATTERN =
|
||||
`(?<![\\w.])(?<!\\bver\\s)(?<!\\bversion\\s)(?:${STRICT_IPV4_OCTET_PATTERN}\\.){3}${STRICT_IPV4_OCTET_PATTERN}(?![\\w.])`;
|
||||
// Covers full and compressed forms (1:2:3:4:5:6:7:8, fe80::1, ::1, 2001:db8::,
|
||||
// etc.). Bracketed `[…]:port` URLs are matched by URL_HIGHLIGHT_PATTERN.
|
||||
// Zone IDs (%eth0) and IPv4-mapped (::ffff:192.0.2.1) are intentionally out
|
||||
// of scope here — add them as custom patterns if you need them.
|
||||
const IPV6_HIGHLIGHT_PATTERN =
|
||||
'(?<![\\w:.])' +
|
||||
'(?:' +
|
||||
'(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}' +
|
||||
'|(?:[0-9A-Fa-f]{1,4}:){1,7}:' +
|
||||
'|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}' +
|
||||
'|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}' +
|
||||
'|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}' +
|
||||
'|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}' +
|
||||
'|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}' +
|
||||
'|[0-9A-Fa-f]{1,4}:(?::[0-9A-Fa-f]{1,4}){1,6}' +
|
||||
'|::(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4}' +
|
||||
')' +
|
||||
'(?![\\w:.])';
|
||||
const MAC_ADDRESS_HIGHLIGHT_PATTERN =
|
||||
'\\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\\b';
|
||||
|
||||
@@ -569,7 +592,7 @@ export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
|
||||
{ id: 'ok', label: 'OK', patterns: ['\\[ok\\]', '\\bok\\b', '\\bsuccess(ful)?\\b', '\\bpassed\\b', '\\bcompleted\\b', '\\bdone\\b'], color: '#34D399', enabled: true },
|
||||
{ id: 'info', label: 'Info', patterns: ['\\[info\\]', '\\[notice\\]', '\\[note\\]', '\\bnotice\\b', '\\bnote\\b'], color: '#3B82F6', enabled: true },
|
||||
{ id: 'debug', label: 'Debug', patterns: ['\\[debug\\]', '\\[trace\\]', '\\[verbose\\]', '\\bdebug\\b', '\\btrace\\b', '\\bverbose\\b'], color: '#A78BFA', enabled: true },
|
||||
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
|
||||
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, IPV6_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
|
||||
];
|
||||
|
||||
const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlightRule => ({
|
||||
@@ -594,6 +617,21 @@ const normalizeKeywordHighlightRules = (
|
||||
return cloneKeywordHighlightRule(rule);
|
||||
}
|
||||
|
||||
// A built-in rule the user has explicitly edited keeps its label/patterns;
|
||||
// otherwise we re-sync with the latest defaults so newly shipped patterns
|
||||
// (e.g. the IPv6 entry in `ip-mac`) propagate to existing users without
|
||||
// a manual reset.
|
||||
if (rule.customized) {
|
||||
return {
|
||||
...defaultRule,
|
||||
label: rule.label,
|
||||
patterns: [...rule.patterns],
|
||||
color: rule.color,
|
||||
enabled: rule.enabled,
|
||||
customized: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultRule,
|
||||
color: rule.color,
|
||||
|
||||
@@ -348,6 +348,18 @@ async function readKnownHosts() {
|
||||
return combinedContent || null;
|
||||
}
|
||||
|
||||
async function listDrives() {
|
||||
if (process.platform !== "win32") return [];
|
||||
const letters = [];
|
||||
for (let i = 65; i <= 90; i++) {
|
||||
letters.push(String.fromCharCode(i));
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
letters.map((letter) => fs.promises.access(letter + ":\\"))
|
||||
);
|
||||
return letters.filter((_, idx) => results[idx].status === "fulfilled").map((letter) => letter + ":");
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for local filesystem operations
|
||||
*/
|
||||
@@ -361,6 +373,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:local:stat", statLocal);
|
||||
ipcMain.handle("netcatty:local:tree", listLocalTree);
|
||||
ipcMain.handle("netcatty:local:homedir", getHomeDir);
|
||||
ipcMain.handle("netcatty:local:drives", listDrives);
|
||||
ipcMain.handle("netcatty:system:info", getSystemInfo);
|
||||
ipcMain.handle("netcatty:known-hosts:read", readKnownHosts);
|
||||
}
|
||||
@@ -377,6 +390,7 @@ module.exports = {
|
||||
collectLocalTreeEntries,
|
||||
listLocalTree,
|
||||
getHomeDir,
|
||||
listDrives,
|
||||
getSystemInfo,
|
||||
readKnownHosts,
|
||||
parseAttribOutput,
|
||||
|
||||
402
electron/bridges/telnetProtocol.cjs
Normal file
402
electron/bridges/telnetProtocol.cjs
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Telnet protocol helpers — RFC 854 framing + RFC 858/1091/1184/1408 options.
|
||||
*
|
||||
* The pieces live here so the protocol layer can be exercised by unit tests
|
||||
* without spinning up a socket. terminalBridge.cjs owns the policy (which
|
||||
* options to enable, what TERM-TYPE to advertise, how to wire data back to
|
||||
* the renderer), this module owns the parsing.
|
||||
*/
|
||||
|
||||
// Command bytes (RFC 854 / RFC 855).
|
||||
const IAC = 255;
|
||||
const SE = 240;
|
||||
const NOP = 241;
|
||||
const DM = 242;
|
||||
const BRK = 243;
|
||||
const IP = 244;
|
||||
const AO = 245;
|
||||
const AYT = 246;
|
||||
const EC = 247;
|
||||
const EL = 248;
|
||||
const GA = 249;
|
||||
const SB = 250;
|
||||
const WILL = 251;
|
||||
const WONT = 252;
|
||||
const DO = 253;
|
||||
const DONT = 254;
|
||||
|
||||
// Options we care about. Servers may negotiate plenty of others, but we only
|
||||
// surface these to the policy layer; everything else is rejected with DONT/
|
||||
// WONT so the conversation terminates cleanly.
|
||||
const OPT = {
|
||||
ECHO: 1, // RFC 857
|
||||
SUPPRESS_GO_AHEAD: 3, // RFC 858
|
||||
STATUS: 5,
|
||||
TERMINAL_TYPE: 24, // RFC 1091
|
||||
NAWS: 31, // RFC 1073 — window size
|
||||
TERMINAL_SPEED: 32,
|
||||
LINEMODE: 34,
|
||||
NEW_ENVIRON: 39,
|
||||
};
|
||||
|
||||
const SUBOPTION_IS = 0;
|
||||
const SUBOPTION_SEND = 1;
|
||||
|
||||
const isOptionCommand = (cmd) =>
|
||||
cmd === WILL || cmd === WONT || cmd === DO || cmd === DONT;
|
||||
|
||||
const commandName = (cmd) => {
|
||||
switch (cmd) {
|
||||
case IAC: return "IAC";
|
||||
case SE: return "SE";
|
||||
case NOP: return "NOP";
|
||||
case DM: return "DM";
|
||||
case BRK: return "BRK";
|
||||
case IP: return "IP";
|
||||
case AO: return "AO";
|
||||
case AYT: return "AYT";
|
||||
case EC: return "EC";
|
||||
case EL: return "EL";
|
||||
case GA: return "GA";
|
||||
case SB: return "SB";
|
||||
case WILL: return "WILL";
|
||||
case WONT: return "WONT";
|
||||
case DO: return "DO";
|
||||
case DONT: return "DONT";
|
||||
default: return String(cmd);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape a buffer for wire transmission: any literal 0xFF byte becomes
|
||||
* 0xFF 0xFF so the peer's parser does not treat it as IAC. Cheap fast-path
|
||||
* for the common case (no 0xFF bytes) so user typing — which is UTF-8 and
|
||||
* cannot contain 0xFF — pays nothing.
|
||||
*/
|
||||
function escapeIacForWire(buf) {
|
||||
if (!Buffer.isBuffer(buf) || buf.length === 0) return buf;
|
||||
if (buf.indexOf(0xff) < 0) return buf;
|
||||
const out = [];
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
out.push(buf[i]);
|
||||
if (buf[i] === 0xff) out.push(0xff);
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stateful Telnet parser.
|
||||
*
|
||||
* The parser preserves any partial command (IAC alone, IAC + verb without
|
||||
* option, or unterminated subnegotiation) between feeds so that a sequence
|
||||
* split across TCP frames is reassembled before being acted on. The previous
|
||||
* stateless approach would either drop the lone IAC or treat the tail of an
|
||||
* unterminated SB block as data — exactly the source of the garbled-output
|
||||
* symptom on chatty old equipment.
|
||||
*
|
||||
* Callbacks:
|
||||
* onCommand(cmd, opt) — WILL/WONT/DO/DONT for `opt`.
|
||||
* onSubnegotiation(opt, buf) — IAC SB <opt> ... IAC SE. `buf` is the
|
||||
* payload between option byte and IAC SE,
|
||||
* with any IAC IAC unescaped to a single
|
||||
* 0xFF.
|
||||
* onData(buf) — clean stream bytes, IAC IAC already
|
||||
* unescaped. Emitted in chunks coinciding
|
||||
* with command boundaries; never empty.
|
||||
*/
|
||||
function createTelnetParser({ onCommand, onSubnegotiation, onData } = {}) {
|
||||
let pending = Buffer.alloc(0);
|
||||
|
||||
const noop = () => {};
|
||||
const handleCommand = typeof onCommand === "function" ? onCommand : noop;
|
||||
const handleSubnegotiation = typeof onSubnegotiation === "function" ? onSubnegotiation : noop;
|
||||
const handleData = typeof onData === "function" ? onData : noop;
|
||||
|
||||
const feed = (chunk) => {
|
||||
if (!chunk || chunk.length === 0) return;
|
||||
const buf = pending.length === 0
|
||||
? (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
: Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
pending = Buffer.alloc(0);
|
||||
|
||||
const out = [];
|
||||
let i = 0;
|
||||
const flushData = () => {
|
||||
if (out.length > 0) {
|
||||
handleData(Buffer.from(out));
|
||||
out.length = 0;
|
||||
}
|
||||
};
|
||||
|
||||
while (i < buf.length) {
|
||||
const byte = buf[i];
|
||||
if (byte !== IAC) {
|
||||
out.push(byte);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// We are at an IAC byte. Need at least one more byte to know the verb.
|
||||
if (i + 1 >= buf.length) {
|
||||
pending = buf.subarray(i);
|
||||
break;
|
||||
}
|
||||
|
||||
const cmd = buf[i + 1];
|
||||
|
||||
if (cmd === IAC) {
|
||||
// Escaped literal 0xFF in the data stream.
|
||||
out.push(0xff);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isOptionCommand(cmd)) {
|
||||
if (i + 2 >= buf.length) {
|
||||
pending = buf.subarray(i);
|
||||
break;
|
||||
}
|
||||
const opt = buf[i + 2];
|
||||
flushData();
|
||||
handleCommand(cmd, opt);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmd === SB) {
|
||||
// Subnegotiation: IAC SB <opt> <payload...> IAC SE. We need to find
|
||||
// the terminating IAC SE while ignoring escaped IAC IAC inside the
|
||||
// payload (RFC 855).
|
||||
let j = i + 3;
|
||||
let seFound = false;
|
||||
while (j + 1 < buf.length) {
|
||||
if (buf[j] === IAC) {
|
||||
if (buf[j + 1] === SE) {
|
||||
seFound = true;
|
||||
break;
|
||||
}
|
||||
// Escaped IAC IAC in payload, or another IAC verb (rare,
|
||||
// ignored). Either way, skip two bytes and keep scanning.
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
if (!seFound) {
|
||||
// Subnegotiation continues into the next frame.
|
||||
pending = buf.subarray(i);
|
||||
break;
|
||||
}
|
||||
if (i + 2 >= buf.length) {
|
||||
pending = buf.subarray(i);
|
||||
break;
|
||||
}
|
||||
const opt = buf[i + 2];
|
||||
const rawPayload = buf.subarray(i + 3, j);
|
||||
const payload = unescapeIacFromPayload(rawPayload);
|
||||
flushData();
|
||||
handleSubnegotiation(opt, payload);
|
||||
i = j + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Other single-verb IAC commands (NOP, AYT, IP, ...). The protocol
|
||||
// does not require us to act, but we must still consume the two bytes
|
||||
// so they do not leak into the data stream.
|
||||
flushData();
|
||||
i += 2;
|
||||
}
|
||||
|
||||
flushData();
|
||||
};
|
||||
|
||||
return {
|
||||
feed,
|
||||
get pendingByteCount() {
|
||||
return pending.length;
|
||||
},
|
||||
/** Reset state — used when a session is torn down or reconnected. */
|
||||
reset() {
|
||||
pending = Buffer.alloc(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function unescapeIacFromPayload(buf) {
|
||||
if (!buf || buf.indexOf(0xff) < 0) return buf;
|
||||
const out = [];
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf[i] === 0xff && i + 1 < buf.length && buf[i + 1] === 0xff) {
|
||||
out.push(0xff);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
out.push(buf[i]);
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Telnet negotiation policy machine.
|
||||
*
|
||||
* The machine owns the rules for which options we accept, the
|
||||
* direction-aware acknowledgement tracking, and the wire bytes sent in
|
||||
* response to peer commands. It is intentionally separated from socket I/O
|
||||
* so it can be exercised directly in unit tests.
|
||||
*
|
||||
* `writeCommand(cmd, opt)` is invoked to send `IAC <cmd> <opt>` on the
|
||||
* wire; `writeSubnegotiation(opt, payload)` is invoked to send
|
||||
* `IAC SB <opt> <payload...> IAC SE` (with `payload` already escaped if
|
||||
* needed by the caller); `getWindowSize()` returns `{ cols, rows }` for
|
||||
* the current terminal dimensions; `termType` is the string advertised
|
||||
* for TERMINAL-TYPE subnegotiation (default "XTERM-256COLOR").
|
||||
*/
|
||||
function createTelnetNegotiator({
|
||||
writeCommand,
|
||||
writeSubnegotiation,
|
||||
getWindowSize,
|
||||
termType = "XTERM-256COLOR",
|
||||
} = {}) {
|
||||
const pendingDoRequests = new Set();
|
||||
const pendingWillRequests = new Set();
|
||||
|
||||
const noopWrite = () => {};
|
||||
const cmdSink = typeof writeCommand === "function" ? writeCommand : noopWrite;
|
||||
const sbSink = typeof writeSubnegotiation === "function" ? writeSubnegotiation : noopWrite;
|
||||
const sizeFn = typeof getWindowSize === "function"
|
||||
? getWindowSize
|
||||
: () => ({ cols: 80, rows: 24 });
|
||||
|
||||
const naws = () => {
|
||||
const { cols, rows } = sizeFn() || {};
|
||||
const safeCols = Number.isFinite(cols) && cols > 0 ? cols : 80;
|
||||
const safeRows = Number.isFinite(rows) && rows > 0 ? rows : 24;
|
||||
const payload = Buffer.from([
|
||||
(safeCols >> 8) & 0xff, safeCols & 0xff,
|
||||
(safeRows >> 8) & 0xff, safeRows & 0xff,
|
||||
]);
|
||||
sbSink(OPT.NAWS, escapeIacForWire(payload));
|
||||
};
|
||||
|
||||
const sendTerminalType = () => {
|
||||
sbSink(
|
||||
OPT.TERMINAL_TYPE,
|
||||
Buffer.concat([
|
||||
Buffer.from([SUBOPTION_IS]),
|
||||
Buffer.from(String(termType), "ascii"),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
const requestOption = (cmd, opt) => {
|
||||
if (cmd === DO) pendingDoRequests.add(opt);
|
||||
else if (cmd === WILL) pendingWillRequests.add(opt);
|
||||
cmdSink(cmd, opt);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
// Drive the negotiation rather than waiting for the peer. Many legacy
|
||||
// servers will not advance past their banner until the client commits
|
||||
// to a basic option set.
|
||||
requestOption(DO, OPT.SUPPRESS_GO_AHEAD);
|
||||
requestOption(WILL, OPT.TERMINAL_TYPE);
|
||||
requestOption(WILL, OPT.NAWS);
|
||||
};
|
||||
|
||||
const handleCommand = (cmd, opt) => {
|
||||
let acknowledgesOurRequest = false;
|
||||
if ((cmd === WILL || cmd === WONT) && pendingDoRequests.has(opt)) {
|
||||
pendingDoRequests.delete(opt);
|
||||
acknowledgesOurRequest = true;
|
||||
} else if ((cmd === DO || cmd === DONT) && pendingWillRequests.has(opt)) {
|
||||
pendingWillRequests.delete(opt);
|
||||
acknowledgesOurRequest = true;
|
||||
}
|
||||
|
||||
if (cmd === WILL) {
|
||||
if (!acknowledgesOurRequest) {
|
||||
if (opt === OPT.SUPPRESS_GO_AHEAD || opt === OPT.ECHO) {
|
||||
cmdSink(DO, opt);
|
||||
} else {
|
||||
cmdSink(DONT, opt);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === DO) {
|
||||
if (opt === OPT.NAWS) {
|
||||
if (!acknowledgesOurRequest) cmdSink(WILL, opt);
|
||||
// Always follow through with the actual size, whether this DO is the
|
||||
// peer's reply to our WILL or an independent fresh request.
|
||||
naws();
|
||||
} else if (opt === OPT.TERMINAL_TYPE || opt === OPT.SUPPRESS_GO_AHEAD) {
|
||||
if (!acknowledgesOurRequest) cmdSink(WILL, opt);
|
||||
} else {
|
||||
if (!acknowledgesOurRequest) cmdSink(WONT, opt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === DONT) {
|
||||
if (!acknowledgesOurRequest) cmdSink(WONT, opt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === WONT) {
|
||||
if (!acknowledgesOurRequest) cmdSink(DONT, opt);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubnegotiation = (opt, payload) => {
|
||||
if (opt === OPT.TERMINAL_TYPE
|
||||
&& payload && payload.length > 0
|
||||
&& payload[0] === SUBOPTION_SEND) {
|
||||
sendTerminalType();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start,
|
||||
handleCommand,
|
||||
handleSubnegotiation,
|
||||
sendWindowSize: naws,
|
||||
/** Test/debug introspection — number of options awaiting a reply per direction. */
|
||||
get pendingDoCount() {
|
||||
return pendingDoRequests.size;
|
||||
},
|
||||
get pendingWillCount() {
|
||||
return pendingWillRequests.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Command constants
|
||||
IAC,
|
||||
SE,
|
||||
NOP,
|
||||
DM,
|
||||
BRK,
|
||||
IP,
|
||||
AO,
|
||||
AYT,
|
||||
EC,
|
||||
EL,
|
||||
GA,
|
||||
SB,
|
||||
WILL,
|
||||
WONT,
|
||||
DO,
|
||||
DONT,
|
||||
// Options
|
||||
OPT,
|
||||
SUBOPTION_IS,
|
||||
SUBOPTION_SEND,
|
||||
// Helpers
|
||||
commandName,
|
||||
escapeIacForWire,
|
||||
createTelnetParser,
|
||||
createTelnetNegotiator,
|
||||
};
|
||||
409
electron/bridges/telnetProtocol.test.cjs
Normal file
409
electron/bridges/telnetProtocol.test.cjs
Normal file
@@ -0,0 +1,409 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
IAC,
|
||||
SE,
|
||||
NOP,
|
||||
SB,
|
||||
WILL,
|
||||
WONT,
|
||||
DO,
|
||||
DONT,
|
||||
OPT,
|
||||
SUBOPTION_IS,
|
||||
SUBOPTION_SEND,
|
||||
escapeIacForWire,
|
||||
createTelnetParser,
|
||||
createTelnetNegotiator,
|
||||
} = require("./telnetProtocol.cjs");
|
||||
|
||||
const collect = () => {
|
||||
const data = [];
|
||||
const commands = [];
|
||||
const subnegs = [];
|
||||
return {
|
||||
data,
|
||||
commands,
|
||||
subnegs,
|
||||
parser: createTelnetParser({
|
||||
onData(buf) {
|
||||
data.push(Buffer.from(buf));
|
||||
},
|
||||
onCommand(cmd, opt) {
|
||||
commands.push({ cmd, opt });
|
||||
},
|
||||
onSubnegotiation(opt, payload) {
|
||||
subnegs.push({ opt, payload: Buffer.from(payload) });
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
test("escapeIacForWire — passthrough when no 0xFF byte", () => {
|
||||
const input = Buffer.from([0x61, 0x62, 0x63]);
|
||||
assert.equal(escapeIacForWire(input), input);
|
||||
});
|
||||
|
||||
test("escapeIacForWire — doubles each 0xFF", () => {
|
||||
const input = Buffer.from([0xff, 0x61, 0xff, 0xff, 0x62]);
|
||||
const got = escapeIacForWire(input);
|
||||
assert.deepEqual(
|
||||
[...got],
|
||||
[0xff, 0xff, 0x61, 0xff, 0xff, 0xff, 0xff, 0x62],
|
||||
);
|
||||
});
|
||||
|
||||
test("parser emits clean data when no IAC bytes are present", () => {
|
||||
const { parser, data, commands, subnegs } = collect();
|
||||
parser.feed(Buffer.from("hello world"));
|
||||
assert.equal(Buffer.concat(data).toString("utf8"), "hello world");
|
||||
assert.equal(commands.length, 0);
|
||||
assert.equal(subnegs.length, 0);
|
||||
});
|
||||
|
||||
test("parser handles a complete DO option command in one feed", () => {
|
||||
const { parser, data, commands } = collect();
|
||||
parser.feed(Buffer.from([IAC, DO, OPT.SUPPRESS_GO_AHEAD]));
|
||||
assert.equal(data.length, 0);
|
||||
assert.deepEqual(commands, [{ cmd: DO, opt: OPT.SUPPRESS_GO_AHEAD }]);
|
||||
});
|
||||
|
||||
test("parser splits clean data around an option command", () => {
|
||||
const { parser, data, commands } = collect();
|
||||
parser.feed(
|
||||
Buffer.concat([
|
||||
Buffer.from("login: "),
|
||||
Buffer.from([IAC, WILL, OPT.ECHO]),
|
||||
Buffer.from("admin"),
|
||||
]),
|
||||
);
|
||||
assert.equal(Buffer.concat(data).toString("utf8"), "login: admin");
|
||||
assert.deepEqual(commands, [{ cmd: WILL, opt: OPT.ECHO }]);
|
||||
});
|
||||
|
||||
test("parser unescapes IAC IAC into a literal 0xFF in the data stream", () => {
|
||||
const { parser, data } = collect();
|
||||
parser.feed(Buffer.from([0x61, IAC, IAC, 0x62]));
|
||||
assert.deepEqual([...Buffer.concat(data)], [0x61, 0xff, 0x62]);
|
||||
});
|
||||
|
||||
test("parser ignores stand-alone IAC verbs (NOP)", () => {
|
||||
const { parser, data, commands } = collect();
|
||||
parser.feed(Buffer.from([0x61, IAC, NOP, 0x62]));
|
||||
assert.deepEqual([...Buffer.concat(data)], [0x61, 0x62]);
|
||||
assert.equal(commands.length, 0);
|
||||
});
|
||||
|
||||
test("parser parses a complete subnegotiation in one feed", () => {
|
||||
const { parser, data, subnegs } = collect();
|
||||
// IAC SB TERMINAL_TYPE IS "XTERM" IAC SE
|
||||
parser.feed(
|
||||
Buffer.concat([
|
||||
Buffer.from([IAC, SB, OPT.TERMINAL_TYPE, 0]),
|
||||
Buffer.from("XTERM"),
|
||||
Buffer.from([IAC, SE]),
|
||||
]),
|
||||
);
|
||||
assert.equal(data.length, 0);
|
||||
assert.equal(subnegs.length, 1);
|
||||
assert.equal(subnegs[0].opt, OPT.TERMINAL_TYPE);
|
||||
assert.deepEqual(
|
||||
[...subnegs[0].payload],
|
||||
[0, 0x58, 0x54, 0x45, 0x52, 0x4d],
|
||||
);
|
||||
});
|
||||
|
||||
test("parser tolerates IAC IAC inside a subnegotiation payload", () => {
|
||||
const { parser, subnegs } = collect();
|
||||
// SB STATUS 0xFF (encoded as IAC IAC) 0x01 SE
|
||||
parser.feed(Buffer.from([IAC, SB, OPT.STATUS, IAC, IAC, 0x01, IAC, SE]));
|
||||
assert.equal(subnegs.length, 1);
|
||||
assert.deepEqual([...subnegs[0].payload], [0xff, 0x01]);
|
||||
});
|
||||
|
||||
test("parser preserves a lone IAC at end-of-chunk for the next feed", () => {
|
||||
const { parser, data, commands } = collect();
|
||||
parser.feed(Buffer.concat([Buffer.from("hi"), Buffer.from([IAC])]));
|
||||
assert.equal(Buffer.concat(data).toString("utf8"), "hi");
|
||||
assert.equal(commands.length, 0);
|
||||
assert.equal(parser.pendingByteCount, 1);
|
||||
|
||||
// Next chunk completes the command.
|
||||
parser.feed(Buffer.from([DO, OPT.NAWS, 0x61]));
|
||||
assert.equal(parser.pendingByteCount, 0);
|
||||
assert.deepEqual(commands, [{ cmd: DO, opt: OPT.NAWS }]);
|
||||
// The trailing 'a' must have been emitted as data.
|
||||
assert.equal(Buffer.concat(data).toString("utf8"), "hia");
|
||||
});
|
||||
|
||||
test("parser preserves a half-finished option command (IAC DO) for the next feed", () => {
|
||||
const { parser, data, commands } = collect();
|
||||
parser.feed(Buffer.from([0x61, IAC, DO]));
|
||||
assert.equal(Buffer.concat(data).toString("utf8"), "a");
|
||||
assert.equal(commands.length, 0);
|
||||
assert.equal(parser.pendingByteCount, 2);
|
||||
|
||||
parser.feed(Buffer.from([OPT.TERMINAL_TYPE, 0x62]));
|
||||
assert.deepEqual(commands, [{ cmd: DO, opt: OPT.TERMINAL_TYPE }]);
|
||||
assert.equal(Buffer.concat(data).toString("utf8"), "ab");
|
||||
});
|
||||
|
||||
test("parser preserves an unterminated subnegotiation across multiple frames", () => {
|
||||
const { parser, data, subnegs } = collect();
|
||||
// Send IAC SB TT 0 "XTE" — the SE is intentionally missing.
|
||||
parser.feed(
|
||||
Buffer.concat([
|
||||
Buffer.from("prefix"),
|
||||
Buffer.from([IAC, SB, OPT.TERMINAL_TYPE, 0]),
|
||||
Buffer.from("XTE"),
|
||||
]),
|
||||
);
|
||||
assert.equal(Buffer.concat(data).toString("utf8"), "prefix");
|
||||
assert.equal(subnegs.length, 0);
|
||||
|
||||
// Now the remaining payload + IAC SE arrive together with trailing data.
|
||||
parser.feed(
|
||||
Buffer.concat([
|
||||
Buffer.from("RM-256COLOR"),
|
||||
Buffer.from([IAC, SE]),
|
||||
Buffer.from(" tail"),
|
||||
]),
|
||||
);
|
||||
|
||||
assert.equal(subnegs.length, 1);
|
||||
assert.equal(subnegs[0].opt, OPT.TERMINAL_TYPE);
|
||||
assert.deepEqual(
|
||||
Buffer.from(subnegs[0].payload).toString("utf8"),
|
||||
"\x00XTERM-256COLOR",
|
||||
);
|
||||
assert.equal(Buffer.concat(data).toString("utf8"), "prefix tail");
|
||||
});
|
||||
|
||||
test("parser does not leak SB payload as data when the SE never arrives", () => {
|
||||
// Regression: in the old stateless implementation, an unterminated SB block
|
||||
// would fall through to "skip IAC SB and emit the rest as data" — leaking
|
||||
// option-type names and other text into the terminal.
|
||||
const { parser, data, subnegs } = collect();
|
||||
parser.feed(
|
||||
Buffer.concat([
|
||||
Buffer.from([IAC, SB, OPT.TERMINAL_TYPE, 0]),
|
||||
Buffer.from("XTERM-PARTIAL"),
|
||||
]),
|
||||
);
|
||||
assert.equal(data.length, 0);
|
||||
assert.equal(subnegs.length, 0);
|
||||
assert.ok(parser.pendingByteCount > 0);
|
||||
});
|
||||
|
||||
test("parser handles two consecutive option commands without losing either", () => {
|
||||
const { parser, commands } = collect();
|
||||
parser.feed(
|
||||
Buffer.from([IAC, WILL, OPT.ECHO, IAC, DO, OPT.SUPPRESS_GO_AHEAD]),
|
||||
);
|
||||
assert.deepEqual(commands, [
|
||||
{ cmd: WILL, opt: OPT.ECHO },
|
||||
{ cmd: DO, opt: OPT.SUPPRESS_GO_AHEAD },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parser feed is no-op for empty / null chunks", () => {
|
||||
const { parser, data, commands } = collect();
|
||||
parser.feed(Buffer.alloc(0));
|
||||
parser.feed(null);
|
||||
parser.feed(undefined);
|
||||
assert.equal(data.length, 0);
|
||||
assert.equal(commands.length, 0);
|
||||
});
|
||||
|
||||
test("parser reset clears pending state", () => {
|
||||
const { parser } = collect();
|
||||
parser.feed(Buffer.from([IAC]));
|
||||
assert.equal(parser.pendingByteCount, 1);
|
||||
parser.reset();
|
||||
assert.equal(parser.pendingByteCount, 0);
|
||||
});
|
||||
|
||||
const recordNegotiator = (overrides = {}) => {
|
||||
const commands = [];
|
||||
const subnegs = [];
|
||||
const negotiator = createTelnetNegotiator({
|
||||
writeCommand(cmd, opt) {
|
||||
commands.push({ cmd, opt });
|
||||
},
|
||||
writeSubnegotiation(opt, payload) {
|
||||
subnegs.push({ opt, payload: Buffer.from(payload) });
|
||||
},
|
||||
getWindowSize: () => ({ cols: 120, rows: 40 }),
|
||||
...overrides,
|
||||
});
|
||||
return { negotiator, commands, subnegs };
|
||||
};
|
||||
|
||||
test("negotiator.start drives the canonical handshake (DO SGA / WILL TT / WILL NAWS)", () => {
|
||||
const { negotiator, commands } = recordNegotiator();
|
||||
negotiator.start();
|
||||
assert.deepEqual(commands, [
|
||||
{ cmd: DO, opt: OPT.SUPPRESS_GO_AHEAD },
|
||||
{ cmd: WILL, opt: OPT.TERMINAL_TYPE },
|
||||
{ cmd: WILL, opt: OPT.NAWS },
|
||||
]);
|
||||
assert.equal(negotiator.pendingDoCount, 1);
|
||||
assert.equal(negotiator.pendingWillCount, 2);
|
||||
});
|
||||
|
||||
test("peer's WILL on our pending DO is swallowed (no double-DO loop)", () => {
|
||||
const { negotiator, commands } = recordNegotiator();
|
||||
negotiator.start();
|
||||
commands.length = 0;
|
||||
// Server replies WILL SGA — acknowledges our DO SGA.
|
||||
negotiator.handleCommand(WILL, OPT.SUPPRESS_GO_AHEAD);
|
||||
assert.deepEqual(commands, []);
|
||||
assert.equal(negotiator.pendingDoCount, 0);
|
||||
});
|
||||
|
||||
test("peer's independent DO on a SGA where our DO is still pending is replied with WILL (regression)", () => {
|
||||
// RFC 858: WILL/WONT and DO/DONT are independent per direction. The peer
|
||||
// can ask us to enable SGA on our side while our request to enable it on
|
||||
// its side is still in flight. The old implementation incorrectly treated
|
||||
// the peer's DO as an ack of our DO and never replied.
|
||||
const { negotiator, commands } = recordNegotiator();
|
||||
negotiator.start();
|
||||
commands.length = 0;
|
||||
|
||||
negotiator.handleCommand(DO, OPT.SUPPRESS_GO_AHEAD);
|
||||
|
||||
assert.deepEqual(commands, [{ cmd: WILL, opt: OPT.SUPPRESS_GO_AHEAD }]);
|
||||
// The pending DO request stays open until the peer also says WILL/WONT.
|
||||
assert.equal(negotiator.pendingDoCount, 1);
|
||||
});
|
||||
|
||||
test("peer's DO NAWS that acknowledges our WILL NAWS still triggers a size subnegotiation", () => {
|
||||
const { negotiator, commands, subnegs } = recordNegotiator();
|
||||
negotiator.start();
|
||||
commands.length = 0;
|
||||
subnegs.length = 0;
|
||||
|
||||
negotiator.handleCommand(DO, OPT.NAWS);
|
||||
|
||||
// No echoed WILL NAWS — the peer was acknowledging our own WILL.
|
||||
assert.deepEqual(commands, []);
|
||||
// But the actual size payload must follow.
|
||||
assert.equal(subnegs.length, 1);
|
||||
assert.equal(subnegs[0].opt, OPT.NAWS);
|
||||
assert.deepEqual(
|
||||
[...subnegs[0].payload],
|
||||
[(120 >> 8) & 0xff, 120 & 0xff, (40 >> 8) & 0xff, 40 & 0xff],
|
||||
);
|
||||
assert.equal(negotiator.pendingWillCount, 1); // TERMINAL-TYPE still outstanding
|
||||
});
|
||||
|
||||
test("peer's independent DO NAWS (no WILL pending) replies WILL + size subneg", () => {
|
||||
const { negotiator, commands, subnegs } = recordNegotiator();
|
||||
// Note: not calling start(), so no WILL NAWS is pending.
|
||||
negotiator.handleCommand(DO, OPT.NAWS);
|
||||
assert.deepEqual(commands, [{ cmd: WILL, opt: OPT.NAWS }]);
|
||||
assert.equal(subnegs.length, 1);
|
||||
assert.equal(subnegs[0].opt, OPT.NAWS);
|
||||
});
|
||||
|
||||
test("peer's WILL ECHO triggers DO ECHO, repeated WILL ECHO is swallowed as ack", () => {
|
||||
const { negotiator, commands } = recordNegotiator();
|
||||
// First WILL ECHO: fresh request → we reply DO ECHO. That DO is sent via
|
||||
// the raw writer (not requestOption), so it is NOT in pendingDoRequests.
|
||||
// The peer's subsequent WILL ECHO is a re-announce and we should reply
|
||||
// with another DO. The negotiator does not currently de-duplicate, so
|
||||
// this test pins down the actual behaviour: reply each time.
|
||||
negotiator.handleCommand(WILL, OPT.ECHO);
|
||||
negotiator.handleCommand(WILL, OPT.ECHO);
|
||||
assert.deepEqual(commands, [
|
||||
{ cmd: DO, opt: OPT.ECHO },
|
||||
{ cmd: DO, opt: OPT.ECHO },
|
||||
]);
|
||||
});
|
||||
|
||||
test("peer's WONT on our pending DO is swallowed", () => {
|
||||
const { negotiator, commands } = recordNegotiator();
|
||||
negotiator.start();
|
||||
commands.length = 0;
|
||||
negotiator.handleCommand(WONT, OPT.SUPPRESS_GO_AHEAD);
|
||||
assert.deepEqual(commands, []);
|
||||
assert.equal(negotiator.pendingDoCount, 0);
|
||||
});
|
||||
|
||||
test("peer's DONT on our pending WILL is swallowed", () => {
|
||||
const { negotiator, commands } = recordNegotiator();
|
||||
negotiator.start();
|
||||
commands.length = 0;
|
||||
negotiator.handleCommand(DONT, OPT.TERMINAL_TYPE);
|
||||
assert.deepEqual(commands, []);
|
||||
// NAWS still outstanding.
|
||||
assert.equal(negotiator.pendingWillCount, 1);
|
||||
});
|
||||
|
||||
test("peer's DO on an option we don't support replies WONT", () => {
|
||||
const { negotiator, commands } = recordNegotiator();
|
||||
negotiator.handleCommand(DO, OPT.LINEMODE);
|
||||
assert.deepEqual(commands, [{ cmd: WONT, opt: OPT.LINEMODE }]);
|
||||
});
|
||||
|
||||
test("peer's WILL on an option we don't support replies DONT", () => {
|
||||
const { negotiator, commands } = recordNegotiator();
|
||||
negotiator.handleCommand(WILL, OPT.NEW_ENVIRON);
|
||||
assert.deepEqual(commands, [{ cmd: DONT, opt: OPT.NEW_ENVIRON }]);
|
||||
});
|
||||
|
||||
test("peer's TERMINAL-TYPE SEND subnegotiation replies with IS <termType>", () => {
|
||||
const { negotiator, subnegs } = recordNegotiator();
|
||||
negotiator.handleSubnegotiation(
|
||||
OPT.TERMINAL_TYPE,
|
||||
Buffer.from([SUBOPTION_SEND]),
|
||||
);
|
||||
assert.equal(subnegs.length, 1);
|
||||
assert.equal(subnegs[0].opt, OPT.TERMINAL_TYPE);
|
||||
assert.equal(
|
||||
subnegs[0].payload.toString("ascii"),
|
||||
"\x00XTERM-256COLOR",
|
||||
);
|
||||
});
|
||||
|
||||
test("negotiator honors a custom termType override", () => {
|
||||
const { negotiator, subnegs } = recordNegotiator({ termType: "VT100" });
|
||||
negotiator.handleSubnegotiation(
|
||||
OPT.TERMINAL_TYPE,
|
||||
Buffer.from([SUBOPTION_SEND]),
|
||||
);
|
||||
assert.equal(subnegs[0].payload.toString("ascii"), "\x00VT100");
|
||||
});
|
||||
|
||||
test("sendWindowSize falls back to 80x24 when getWindowSize returns garbage", () => {
|
||||
const { negotiator, subnegs } = recordNegotiator({
|
||||
getWindowSize: () => ({ cols: NaN, rows: -3 }),
|
||||
});
|
||||
negotiator.sendWindowSize();
|
||||
assert.deepEqual([...subnegs[0].payload], [0, 80, 0, 24]);
|
||||
});
|
||||
|
||||
test("data emitted before a command is delivered before that command's callback", () => {
|
||||
const order = [];
|
||||
const parser = createTelnetParser({
|
||||
onData(buf) {
|
||||
order.push(`data:${buf.toString("utf8")}`);
|
||||
},
|
||||
onCommand(cmd, opt) {
|
||||
order.push(`cmd:${cmd}:${opt}`);
|
||||
},
|
||||
});
|
||||
parser.feed(
|
||||
Buffer.concat([
|
||||
Buffer.from("hi"),
|
||||
Buffer.from([IAC, WONT, OPT.LINEMODE]),
|
||||
Buffer.from("bye"),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(order, [
|
||||
"data:hi",
|
||||
`cmd:${WONT}:${OPT.LINEMODE}`,
|
||||
"data:bye",
|
||||
]);
|
||||
});
|
||||
@@ -24,6 +24,7 @@ const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
const moshHandshake = require("./moshHandshake.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
|
||||
const telnetProtocol = require("./telnetProtocol.cjs");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -547,121 +548,61 @@ async function startTelnetSession(event, options) {
|
||||
},
|
||||
});
|
||||
|
||||
// Telnet protocol constants
|
||||
const TELNET = {
|
||||
IAC: 255,
|
||||
DONT: 254,
|
||||
DO: 253,
|
||||
WONT: 252,
|
||||
WILL: 251,
|
||||
SB: 250,
|
||||
SE: 240,
|
||||
ECHO: 1,
|
||||
SUPPRESS_GO_AHEAD: 3,
|
||||
STATUS: 5,
|
||||
TERMINAL_TYPE: 24,
|
||||
NAWS: 31,
|
||||
TERMINAL_SPEED: 32,
|
||||
LINEMODE: 34,
|
||||
NEW_ENVIRON: 39,
|
||||
// Telnet protocol state. Negotiation only activates once we see an IAC
|
||||
// byte from the peer — if the remote never speaks the protocol (some
|
||||
// legacy raw-TCP services on port 23), we fall back to passthrough so we
|
||||
// do not corrupt their stream by misreading stray 0xFF bytes as IAC.
|
||||
let telnetProtocolActive = false;
|
||||
let telnetCleanData = Buffer.alloc(0);
|
||||
|
||||
const writeRawTelnetCommand = (cmd, opt) => {
|
||||
if (socket.destroyed) return;
|
||||
socket.write(Buffer.from([telnetProtocol.IAC, cmd, opt]));
|
||||
};
|
||||
|
||||
const sendWindowSize = () => {
|
||||
const buf = Buffer.from([
|
||||
TELNET.IAC, TELNET.SB, TELNET.NAWS,
|
||||
(cols >> 8) & 0xff, cols & 0xff,
|
||||
(rows >> 8) & 0xff, rows & 0xff,
|
||||
TELNET.IAC, TELNET.SE
|
||||
]);
|
||||
socket.write(buf);
|
||||
const writeRawSubnegotiation = (opt, payload) => {
|
||||
if (socket.destroyed) return;
|
||||
socket.write(Buffer.concat([
|
||||
Buffer.from([telnetProtocol.IAC, telnetProtocol.SB, opt]),
|
||||
payload,
|
||||
Buffer.from([telnetProtocol.IAC, telnetProtocol.SE]),
|
||||
]));
|
||||
};
|
||||
|
||||
const handleTelnetNegotiation = (data) => {
|
||||
const output = [];
|
||||
let i = 0;
|
||||
const negotiator = telnetProtocol.createTelnetNegotiator({
|
||||
writeCommand: writeRawTelnetCommand,
|
||||
writeSubnegotiation: writeRawSubnegotiation,
|
||||
getWindowSize: () => {
|
||||
const session = sessions.get(sessionId);
|
||||
return { cols: session?.cols ?? cols, rows: session?.rows ?? rows };
|
||||
},
|
||||
});
|
||||
|
||||
while (i < data.length) {
|
||||
if (data[i] === TELNET.IAC) {
|
||||
if (i + 1 >= data.length) break;
|
||||
|
||||
const cmd = data[i + 1];
|
||||
|
||||
if (cmd === TELNET.IAC) {
|
||||
output.push(255);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const telnetParser = telnetProtocol.createTelnetParser({
|
||||
onData: (clean) => {
|
||||
if (clean.length === 0) return;
|
||||
telnetCleanData = telnetCleanData.length === 0
|
||||
? clean
|
||||
: Buffer.concat([telnetCleanData, clean]);
|
||||
},
|
||||
onCommand: (cmd, opt) => negotiator.handleCommand(cmd, opt),
|
||||
onSubnegotiation: (opt, payload) => negotiator.handleSubnegotiation(opt, payload),
|
||||
});
|
||||
|
||||
if (cmd === TELNET.DO || cmd === TELNET.DONT || cmd === TELNET.WILL || cmd === TELNET.WONT) {
|
||||
if (i + 2 >= data.length) break;
|
||||
|
||||
const opt = data[i + 2];
|
||||
console.log(`[Telnet] Received: ${cmd === TELNET.DO ? 'DO' : cmd === TELNET.DONT ? 'DONT' : cmd === TELNET.WILL ? 'WILL' : 'WONT'} ${opt}`);
|
||||
|
||||
if (cmd === TELNET.DO) {
|
||||
if (opt === TELNET.NAWS) {
|
||||
socket.write(Buffer.from([TELNET.IAC, TELNET.WILL, opt]));
|
||||
sendWindowSize();
|
||||
} else if (opt === TELNET.TERMINAL_TYPE) {
|
||||
socket.write(Buffer.from([TELNET.IAC, TELNET.WILL, opt]));
|
||||
} else if (opt === TELNET.SUPPRESS_GO_AHEAD) {
|
||||
socket.write(Buffer.from([TELNET.IAC, TELNET.WILL, opt]));
|
||||
} else {
|
||||
socket.write(Buffer.from([TELNET.IAC, TELNET.WONT, opt]));
|
||||
}
|
||||
} else if (cmd === TELNET.WILL) {
|
||||
if (opt === TELNET.ECHO || opt === TELNET.SUPPRESS_GO_AHEAD) {
|
||||
socket.write(Buffer.from([TELNET.IAC, TELNET.DO, opt]));
|
||||
} else {
|
||||
socket.write(Buffer.from([TELNET.IAC, TELNET.DONT, opt]));
|
||||
}
|
||||
} else if (cmd === TELNET.DONT) {
|
||||
socket.write(Buffer.from([TELNET.IAC, TELNET.WONT, opt]));
|
||||
} else if (cmd === TELNET.WONT) {
|
||||
socket.write(Buffer.from([TELNET.IAC, TELNET.DONT, opt]));
|
||||
}
|
||||
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmd === TELNET.SB) {
|
||||
let seIndex = i + 2;
|
||||
while (seIndex < data.length - 1) {
|
||||
if (data[seIndex] === TELNET.IAC && data[seIndex + 1] === TELNET.SE) {
|
||||
break;
|
||||
}
|
||||
seIndex++;
|
||||
}
|
||||
|
||||
if (seIndex < data.length - 1) {
|
||||
const subOpt = data[i + 2];
|
||||
console.log(`[Telnet] Sub-negotiation for option ${subOpt}`);
|
||||
|
||||
if (subOpt === TELNET.TERMINAL_TYPE && data[i + 3] === 1) {
|
||||
const termType = 'xterm-256color';
|
||||
const response = Buffer.concat([
|
||||
Buffer.from([TELNET.IAC, TELNET.SB, TELNET.TERMINAL_TYPE, 0]),
|
||||
Buffer.from(termType),
|
||||
Buffer.from([TELNET.IAC, TELNET.SE])
|
||||
]);
|
||||
socket.write(response);
|
||||
}
|
||||
|
||||
i = seIndex + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push(data[i]);
|
||||
i++;
|
||||
const processIncomingTelnet = (data) => {
|
||||
// Lazy protocol activation: only flip on once we see an IAC from the
|
||||
// peer. Until then we just hand bytes back as-is so true raw-TCP-on-23
|
||||
// services (the long tail of embedded devices) are not corrupted.
|
||||
if (!telnetProtocolActive) {
|
||||
if (data.indexOf(0xff) < 0) return data;
|
||||
telnetProtocolActive = true;
|
||||
negotiator.start();
|
||||
}
|
||||
|
||||
return Buffer.from(output);
|
||||
telnetCleanData = Buffer.alloc(0);
|
||||
telnetParser.feed(data);
|
||||
const out = telnetCleanData;
|
||||
telnetCleanData = Buffer.alloc(0);
|
||||
return out;
|
||||
};
|
||||
|
||||
const connectTimeout = setTimeout(() => {
|
||||
@@ -690,6 +631,12 @@ async function startTelnetSession(event, options) {
|
||||
encoding: initialTelnetEncoding,
|
||||
decoderRef: telnetDecoderRef,
|
||||
autoLogin: telnetAutoLogin,
|
||||
// Mirror of the closure-local `telnetProtocolActive` so the resize
|
||||
// handler (which only sees the session record) can decide whether
|
||||
// to push a NAWS subnegotiation.
|
||||
get telnetProtocolActive() {
|
||||
return telnetProtocolActive;
|
||||
},
|
||||
};
|
||||
session.flushPendingData = flushTelnet;
|
||||
sessions.set(sessionId, session);
|
||||
@@ -769,7 +716,7 @@ async function startTelnetSession(event, options) {
|
||||
|
||||
// Always run Telnet negotiation — even during ZMODEM, the Telnet
|
||||
// layer still escapes 0xFF as IAC IAC and sends control sequences.
|
||||
const cleanData = handleTelnetNegotiation(data);
|
||||
const cleanData = processIncomingTelnet(data);
|
||||
if (cleanData.length > 0) {
|
||||
telnetZmodemSentry.consume(cleanData);
|
||||
}
|
||||
@@ -1610,7 +1557,19 @@ function writeToSession(event, payload) {
|
||||
} else if (session.proc) {
|
||||
session.proc.write(payload.data);
|
||||
} else if (session.socket) {
|
||||
session.socket.write(payload.data);
|
||||
// Telnet only: any 0xFF byte going out the wire must be doubled, or
|
||||
// the peer will treat it as the start of an IAC command sequence and
|
||||
// eat the next byte (RFC 854 §"Data Stream"). UTF-8 keyboard input
|
||||
// never produces 0xFF, but paste of binary content and some legacy
|
||||
// encodings do. Cheap no-op when there is no 0xFF.
|
||||
let outgoing = payload.data;
|
||||
if (session.type === 'telnet-native' && session.telnetProtocolActive) {
|
||||
if (typeof outgoing === 'string') {
|
||||
outgoing = Buffer.from(outgoing, 'utf8');
|
||||
}
|
||||
outgoing = telnetProtocol.escapeIacForWire(outgoing);
|
||||
}
|
||||
session.socket.write(outgoing);
|
||||
} else if (session.serialPort) {
|
||||
session.serialPort.write(payload.data);
|
||||
}
|
||||
@@ -1638,14 +1597,19 @@ function resizeSession(event, payload) {
|
||||
} else if (session.socket && session.type === 'telnet-native') {
|
||||
session.cols = payload.cols;
|
||||
session.rows = payload.rows;
|
||||
const TELNET = { IAC: 255, SB: 250, SE: 240, NAWS: 31 };
|
||||
const buf = Buffer.from([
|
||||
TELNET.IAC, TELNET.SB, TELNET.NAWS,
|
||||
(payload.cols >> 8) & 0xff, payload.cols & 0xff,
|
||||
(payload.rows >> 8) & 0xff, payload.rows & 0xff,
|
||||
TELNET.IAC, TELNET.SE
|
||||
]);
|
||||
session.socket.write(buf);
|
||||
// Only push a NAWS update once the peer has activated the protocol;
|
||||
// sending an IAC sequence to a raw-TCP server would corrupt its stream.
|
||||
if (session.telnetProtocolActive) {
|
||||
const colsByte = Buffer.from([
|
||||
(payload.cols >> 8) & 0xff, payload.cols & 0xff,
|
||||
(payload.rows >> 8) & 0xff, payload.rows & 0xff,
|
||||
]);
|
||||
session.socket.write(Buffer.concat([
|
||||
Buffer.from([telnetProtocol.IAC, telnetProtocol.SB, telnetProtocol.OPT.NAWS]),
|
||||
telnetProtocol.escapeIacForWire(colsByte),
|
||||
Buffer.from([telnetProtocol.IAC, telnetProtocol.SE]),
|
||||
]));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
|
||||
|
||||
@@ -856,6 +856,9 @@ const api = {
|
||||
getHomeDir: async () => {
|
||||
return ipcRenderer.invoke("netcatty:local:homedir");
|
||||
},
|
||||
listDrives: async () => {
|
||||
return ipcRenderer.invoke("netcatty:local:drives");
|
||||
},
|
||||
getSystemInfo: async () => {
|
||||
return ipcRenderer.invoke("netcatty:system:info");
|
||||
},
|
||||
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -520,6 +520,7 @@ declare global {
|
||||
lastModified: number;
|
||||
}>>;
|
||||
getHomeDir?(): Promise<string>;
|
||||
listDrives?(): Promise<string[]>;
|
||||
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
||||
|
||||
setTheme?(theme: 'light' | 'dark' | 'system'): Promise<boolean>;
|
||||
|
||||
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