feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,252 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { Modal, Input, List, Tag, Typography, theme } from 'antd';
import { Search, MessageSquare, Settings, Network, Plus, PanelLeftClose, Sparkles, ImagePlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useUIStore } from '@/stores';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
export interface CommandPaletteProps {
open: boolean;
onClose: () => void;
}
interface Command {
id: string;
label: string;
icon: React.ReactNode;
shortcut?: string;
category: string;
action: () => void;
}
export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const setActivePage = useUIStore((s) => s.setActivePage);
const setSettingsSection = useUIStore((s) => s.setSettingsSection);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const commands = useMemo<Command[]>(() => {
const nav = t('commandPalette.navigation');
const actions = t('commandPalette.actions');
const settings = t('commandPalette.settings');
const openSettingsShortcut = formatShortcutForDisplay('CmdOrCtrl+,');
const newConversationShortcut = formatShortcutForDisplay('CmdOrCtrl+N');
const searchConversationsShortcut = formatShortcutForDisplay('CmdOrCtrl+F');
return [
{
id: 'go-chat',
label: t('commandPalette.goToChat'),
icon: <MessageSquare size={16} />,
category: nav,
action: () => { setActivePage('chat'); onClose(); },
},
{
id: 'go-settings',
label: t('commandPalette.goToSettings'),
icon: <Settings size={16} />,
shortcut: openSettingsShortcut,
category: nav,
action: () => { setActivePage('settings'); onClose(); },
},
{
id: 'go-drawing',
label: t('commandPalette.goToDrawing'),
icon: <ImagePlus size={16} />,
category: nav,
action: () => { setActivePage('drawing'); onClose(); },
},
{
id: 'go-gateway',
label: t('commandPalette.goToGateway'),
icon: <Network size={16} />,
category: nav,
action: () => { setActivePage('gateway'); onClose(); },
},
{
id: 'go-skills',
label: t('commandPalette.goToSkills'),
icon: <Sparkles size={16} />,
category: nav,
action: () => { setActivePage('skills'); onClose(); },
},
{
id: 'new-conversation',
label: t('commandPalette.newConversation'),
icon: <Plus size={16} />,
shortcut: newConversationShortcut,
category: actions,
action: () => { setActivePage('chat'); onClose(); },
},
{
id: 'toggle-sidebar',
label: t('commandPalette.toggleSidebar'),
icon: <PanelLeftClose size={16} />,
category: actions,
action: () => { toggleSidebar(); onClose(); },
},
{
id: 'search-conversations',
label: t('commandPalette.searchConversations'),
icon: <Search size={16} />,
shortcut: searchConversationsShortcut,
category: actions,
action: () => { setActivePage('chat'); onClose(); },
},
{
id: 'settings-search',
label: `${t('commandPalette.goToSettings')} → ${t('settings.searchProviders.title')}`,
icon: <Settings size={16} />,
category: settings,
action: () => { setActivePage('settings'); setSettingsSection('searchProviders'); onClose(); },
},
{
id: 'settings-mcp',
label: `${t('commandPalette.goToSettings')} → ${t('settings.mcpServers.title')}`,
icon: <Settings size={16} />,
category: settings,
action: () => { setActivePage('settings'); setSettingsSection('mcpServers'); onClose(); },
},
];
}, [t, setActivePage, setSettingsSection, toggleSidebar, onClose]);
const filtered = useMemo(() => {
if (!query.trim()) return commands;
const q = query.toLowerCase();
return commands.filter(
(c) => c.label.toLowerCase().includes(q) || c.category.toLowerCase().includes(q),
);
}, [commands, query]);
useEffect(() => {
setActiveIndex(0);
}, [query]);
useEffect(() => {
if (!open) {
setQuery('');
setActiveIndex(0);
}
}, [open]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % filtered.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
} else if (e.key === 'Enter' && filtered.length > 0) {
e.preventDefault();
filtered[activeIndex]?.action();
}
},
[filtered, activeIndex],
);
// Group commands by category for display
const grouped = useMemo(() => {
const groups: Record<string, Command[]> = {};
for (const cmd of filtered) {
if (!groups[cmd.category]) groups[cmd.category] = [];
groups[cmd.category].push(cmd);
}
return groups;
}, [filtered]);
let flatIndex = 0;
return (
<Modal
open={open}
onCancel={onClose}
mask={{ enabled: true, blur: true }}
footer={null}
closable={false}
centered
width={600}
styles={{ body: { padding: 0 } }}
>
<div onKeyDown={handleKeyDown}>
<Input
prefix={<Search size={16} />}
placeholder={t('commandPalette.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
variant="borderless"
size="large"
autoFocus
style={{ padding: '12px 16px' }}
/>
<div
ref={listRef}
data-os-scrollbar
style={{
maxHeight: 400,
overflowY: 'auto',
borderTop: '1px solid var(--border-color)',
}}
>
{Object.entries(grouped).map(([category, cmds]) => (
<div key={category}>
<Typography.Text
type="secondary"
style={{
display: 'block',
padding: '8px 16px 4px',
fontSize: 12,
fontWeight: 500,
}}
>
{category}
</Typography.Text>
<List
dataSource={cmds}
renderItem={(cmd) => {
const idx = flatIndex++;
const isActive = idx === activeIndex;
return (
<List.Item
key={cmd.id}
onClick={cmd.action}
style={{
cursor: 'pointer',
padding: '8px 16px',
backgroundColor: isActive ? token.colorBgTextHover : undefined,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
gap: 8,
}}
>
<span style={{ fontSize: 16 }}>{cmd.icon}</span>
<span style={{ flex: 1 }}>{cmd.label}</span>
{cmd.shortcut && (
<Tag style={{ margin: 0 }}>{cmd.shortcut}</Tag>
)}
</div>
</List.Item>
);
}}
/>
</div>
))}
{filtered.length === 0 && (
<div style={{ padding: 24, textAlign: 'center' }}>
<Typography.Text type="secondary">{t('common.noData')}</Typography.Text>
</div>
)}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,38 @@
import type { PageKey } from '@/types';
import { ChatPage } from '@/pages/ChatPage';
import { DrawingPage } from '@/pages/DrawingPage';
import { KnowledgePage } from '@/pages/KnowledgePage';
import { MemoryPage } from '@/pages/MemoryPage';
import { GatewayPage } from '@/pages/GatewayPage';
import { FilesPage } from '@/pages/FilesPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { SkillsPage } from '@/pages/SkillsPage';
interface ContentAreaProps {
activePage: PageKey;
}
export function ContentArea({ activePage }: ContentAreaProps) {
switch (activePage) {
case 'chat':
return <ChatPage />;
case 'drawing':
return <DrawingPage />;
case 'knowledge':
return <KnowledgePage />;
case 'memory':
return <MemoryPage />;
case 'gateway':
return <GatewayPage />;
case 'files':
return <FilesPage />;
case 'settings':
return <SettingsPage />;
case 'skills':
return <SkillsPage />;
default: {
const _exhaustive: never = activePage;
throw new Error(`Unhandled page key: ${_exhaustive}`);
}
}
}
@@ -0,0 +1,300 @@
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
import { Copy, TextCursorInput, Bug, Scissors, ClipboardPaste, BoxSelect } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { theme, message } from 'antd';
import { useConversationStore } from '@/stores';
import {
loadDevtoolsContextMenuEnabled,
openDevtoolsWithDiagnostics,
} from '@/lib/desktopCapabilities';
/**
* Global right-click context menu.
* - On textarea/input → Cut + Copy + Paste + Select All + (DevTools in dev)
* - Text selected → Copy + (Fill to Input in chat) + (DevTools in dev)
* - No text, dev mode → DevTools only
* - No text, prod mode → suppress native menu
* - Skips when a component-specific context menu already handled the event.
*/
export function GlobalCopyMenu() {
const { t } = useTranslation();
const { token } = theme.useToken();
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
const [hasSelection, setHasSelection] = useState(false);
const [inChatMessages, setInChatMessages] = useState(false);
const [isTextInput, setIsTextInput] = useState(false);
const selectedTextRef = useRef('');
const menuRef = useRef<HTMLDivElement>(null);
const targetInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
const selectionRangeRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 });
const activeConversationId = useConversationStore((s) => s.activeConversationId);
const isDev = import.meta.env.DEV;
const [devtoolsEnabled, setDevtoolsEnabled] = useState(isDev);
useEffect(() => {
let cancelled = false;
void loadDevtoolsContextMenuEnabled(isDev).then((enabled) => {
if (!cancelled) {
setDevtoolsEnabled(enabled);
}
});
return () => {
cancelled = true;
};
}, [isDev]);
useEffect(() => {
// Save textarea selection before Chromium's auto word-selection on right-click
const savedSelRef = { start: -1, end: -1, el: null as HTMLTextAreaElement | HTMLInputElement | null };
const saveSelectionOnRightClick = (e: MouseEvent) => {
if (e.button !== 2) return;
const t = e.target as HTMLElement;
if (t.tagName === 'TEXTAREA' || (t.tagName === 'INPUT' && (t as HTMLInputElement).type === 'text')) {
const input = t as HTMLTextAreaElement | HTMLInputElement;
savedSelRef.start = input.selectionStart ?? 0;
savedSelRef.end = input.selectionEnd ?? 0;
savedSelRef.el = input;
} else {
savedSelRef.el = null;
}
};
const handler = (e: MouseEvent) => {
if (e.defaultPrevented) return;
if (menuRef.current?.contains(e.target as Node)) return;
e.preventDefault();
const targetEl = e.target as HTMLElement;
const isInput = targetEl.tagName === 'TEXTAREA' || (targetEl.tagName === 'INPUT' && (targetEl as HTMLInputElement).type === 'text');
if (isInput) {
const inputEl = targetEl as HTMLTextAreaElement | HTMLInputElement;
targetInputRef.current = inputEl;
// Use saved selection (before auto word-select) if available
const start = savedSelRef.el === inputEl ? savedSelRef.start : (inputEl.selectionStart ?? 0);
const end = savedSelRef.el === inputEl ? savedSelRef.end : (inputEl.selectionEnd ?? 0);
selectionRangeRef.current = { start, end };
// Restore original selection to undo Chromium's auto word-select (deferred)
inputEl.setSelectionRange(start, end);
requestAnimationFrame(() => {
inputEl.setSelectionRange(start, end);
});
const sel = inputEl.value.substring(start, end);
selectedTextRef.current = sel;
setHasSelection(!!sel);
setIsTextInput(true);
setInChatMessages(false);
setMenuPos({ x: e.clientX, y: e.clientY });
return;
}
setIsTextInput(false);
targetInputRef.current = null;
const sel = window.getSelection()?.toString().trim() || '';
selectedTextRef.current = sel;
// "Fill to input" only when right-clicking inside chat message area
const inMessageArea = !!(e.target as HTMLElement).closest?.('[data-message-area]');
setInChatMessages(inMessageArea);
if (sel) {
setHasSelection(true);
setMenuPos({ x: e.clientX, y: e.clientY });
} else if (devtoolsEnabled) {
setHasSelection(false);
setMenuPos({ x: e.clientX, y: e.clientY });
} else {
setMenuPos(null);
}
};
const dismissHandler = () => setMenuPos(null);
document.addEventListener('mousedown', saveSelectionOnRightClick, true);
document.addEventListener('contextmenu', handler);
document.addEventListener('click', dismissHandler);
document.addEventListener('scroll', dismissHandler, true);
return () => {
document.removeEventListener('mousedown', saveSelectionOnRightClick, true);
document.removeEventListener('contextmenu', handler);
document.removeEventListener('click', dismissHandler);
document.removeEventListener('scroll', dismissHandler, true);
};
}, [devtoolsEnabled]);
const handleCopy = useCallback(() => {
const text = selectedTextRef.current;
if (text) {
void navigator.clipboard.writeText(text);
message.success(t('common.copySuccess'));
}
setMenuPos(null);
}, [t]);
const handleCut = useCallback(() => {
const input = targetInputRef.current;
if (!input) { setMenuPos(null); return; }
const { start, end } = selectionRangeRef.current;
const selectedText = input.value.substring(start, end);
if (selectedText) {
void navigator.clipboard.writeText(selectedText);
input.focus();
input.setSelectionRange(start, end);
document.execCommand('delete');
}
setMenuPos(null);
}, []);
const handlePaste = useCallback(async () => {
const input = targetInputRef.current;
if (!input) { setMenuPos(null); return; }
try {
const text = await navigator.clipboard.readText();
if (text) {
input.focus();
const { start, end } = selectionRangeRef.current;
input.setSelectionRange(start, end);
document.execCommand('insertText', false, text);
}
} catch (err) {
console.error('Paste failed:', err);
}
setMenuPos(null);
}, []);
const handleSelectAll = useCallback(() => {
const input = targetInputRef.current;
if (input) {
input.focus();
input.select();
}
setMenuPos(null);
}, []);
const handleFillInput = useCallback(() => {
const text = selectedTextRef.current;
if (text) {
window.dispatchEvent(new CustomEvent('boosapi:fill-input', { detail: text }));
}
setMenuPos(null);
}, []);
const handleOpenDevtools = useCallback(() => {
void openDevtoolsWithDiagnostics();
setMenuPos(null);
}, []);
// Clamp menu position to stay within viewport
useLayoutEffect(() => {
if (!menuRef.current || !menuPos) return;
const el = menuRef.current;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = menuPos.x;
let y = menuPos.y;
if (x + rect.width > vw) x = vw - rect.width - 4;
if (y + rect.height > vh) y = vh - rect.height - 4;
if (x < 4) x = 4;
if (y < 4) y = 4;
el.style.left = x + 'px';
el.style.top = y + 'px';
}, [menuPos, isTextInput, hasSelection]);
if (!menuPos) return null;
interface MenuItem {
key: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
divider?: boolean;
}
const items: MenuItem[] = [];
if (isTextInput) {
items.push(
{ key: 'cut', icon: <Scissors size={14} />, label: t('common.cut'), onClick: handleCut, disabled: !hasSelection },
{ key: 'copy', icon: <Copy size={14} />, label: t('common.copy'), onClick: handleCopy, disabled: !hasSelection },
{ key: 'paste', icon: <ClipboardPaste size={14} />, label: t('common.paste'), onClick: handlePaste },
{ key: 'selectAll', icon: <BoxSelect size={14} />, label: t('common.selectAll'), onClick: handleSelectAll, divider: true },
);
} else {
if (hasSelection) {
items.push(
{ key: 'copy', icon: <Copy size={14} />, label: t('common.copy'), onClick: handleCopy },
);
if (activeConversationId && inChatMessages) {
items.push({
key: 'fill',
icon: <TextCursorInput size={14} />,
label: t('common.fillToInput'),
onClick: handleFillInput,
});
}
}
}
if (devtoolsEnabled) {
items.push({
key: 'devtools',
icon: <Bug size={14} />,
label: t('common.openDevtools'),
onClick: handleOpenDevtools,
});
}
return (
<div
ref={menuRef}
style={{
position: 'fixed',
left: menuPos.x,
top: menuPos.y,
zIndex: 9999,
backgroundColor: token.colorBgElevated,
borderRadius: 8,
boxShadow: token.boxShadowSecondary,
padding: '4px',
minWidth: 120,
}}
>
{items.map((item) => (
<div key={item.key}>
{item.divider && (
<div style={{ height: 1, backgroundColor: token.colorBorderSecondary, margin: '4px 8px' }} />
)}
<div
className="flex items-center gap-2"
style={{
padding: '6px 12px',
borderRadius: 4,
fontSize: 13,
color: item.disabled ? token.colorTextDisabled : token.colorText,
cursor: item.disabled ? 'default' : 'pointer',
transition: 'background-color 0.15s',
}}
onClick={item.disabled ? undefined : item.onClick}
onMouseEnter={(e) => { if (!item.disabled) (e.currentTarget as HTMLElement).style.backgroundColor = token.colorFillSecondary; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'transparent'; }}
>
{item.icon}
<span>{item.label}</span>
</div>
</div>
))}
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useState } from 'react';
import { Tooltip, Avatar, theme } from 'antd';
import { MessageSquare, BookOpen, Brain, Router, FolderOpen, User, Sparkles, ImagePlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useUIStore, useSettingsStore } from '@/stores';
import { useUserProfileStore } from '@/stores/userProfileStore';
import { getShortcutBinding, formatShortcutForDisplay } from '@/lib/shortcuts';
import type { ShortcutAction } from '@/lib/shortcuts';
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
import { UserProfileModal } from './UserProfileModal';
import type { PageKey } from '@/types';
const mainNavItems: { key: PageKey; icon: React.ReactNode; labelKey: string }[] = [
{ key: 'chat', icon: <MessageSquare size={18} />, labelKey: 'nav.chat' },
{ key: 'drawing', icon: <ImagePlus size={18} />, labelKey: 'nav.drawing' },
{ key: 'skills', icon: <Sparkles size={18} />, labelKey: 'nav.skills' },
{ key: 'knowledge', icon: <BookOpen size={18} />, labelKey: 'nav.knowledge' },
{ key: 'memory', icon: <Brain size={18} />, labelKey: 'nav.memory' },
{ key: 'gateway', icon: <Router size={18} />, labelKey: 'nav.gateway' },
{ key: 'files', icon: <FolderOpen size={18} />, labelKey: 'nav.files' },
];
export function Sidebar() {
const { t } = useTranslation();
const { token } = theme.useToken();
const activePage = useUIStore((s) => s.activePage);
const setActivePage = useUIStore((s) => s.setActivePage);
const profile = useUserProfileStore((s) => s.profile);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const resolvedAvatarSrc = useResolvedAvatarSrc(profile.avatarType, profile.avatarValue);
const settings = useSettingsStore((s) => s.settings);
const NAV_SHORTCUT_MAP: Partial<Record<PageKey, ShortcutAction>> = {
gateway: 'toggleGateway',
};
const renderNavButton = (item: { key: PageKey; icon: React.ReactNode; labelKey: string }) => {
const isActive = activePage === item.key;
const label = t(item.labelKey);
const action = NAV_SHORTCUT_MAP[item.key];
const title = action
? `${label} (${formatShortcutForDisplay(getShortcutBinding(settings, action))})`
: label;
return (
<Tooltip key={item.key} title={title} placement="right">
<button
onClick={() => setActivePage(item.key)}
className="flex items-center justify-center text-base transition-colors"
style={{
width: 36,
height: 36,
borderRadius: '50%',
backgroundColor: isActive ? token.colorPrimaryBg : 'transparent',
color: isActive ? token.colorPrimary : token.colorTextSecondary,
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = token.colorFillSecondary;
e.currentTarget.style.color = token.colorTextBase;
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = token.colorTextSecondary;
}
}}
>
{item.icon}
</button>
</Tooltip>
);
};
const renderUserAvatar = () => {
const size = 32;
if (profile.avatarType === 'emoji' && profile.avatarValue) {
return (
<div
style={{
width: size,
height: size,
borderRadius: '50%',
backgroundColor: token.colorFillSecondary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
cursor: 'pointer',
}}
>
{profile.avatarValue}
</div>
);
}
if ((profile.avatarType === 'url' || profile.avatarType === 'file') && profile.avatarValue) {
const src = profile.avatarType === 'file' ? resolvedAvatarSrc : profile.avatarValue;
return <Avatar size={size} src={src} style={{ cursor: 'pointer' }} />;
}
return (
<Avatar
size={size}
icon={<User size={16} />}
style={{ cursor: 'pointer', backgroundColor: token.colorPrimary }}
/>
);
};
return (
<div className="flex flex-col items-center h-full" style={{ paddingTop: 8, paddingBottom: 12 }}>
<nav className="flex flex-col gap-2">
{mainNavItems.map(renderNavButton)}
</nav>
<div className="flex-1" />
{/* User Avatar */}
<Tooltip title={profile.name || t('userProfile.title')} placement="right">
<button
onClick={() => setProfileModalOpen(true)}
style={{ background: 'none', border: 'none', padding: 0 }}
>
{renderUserAvatar()}
</button>
</Tooltip>
<UserProfileModal open={profileModalOpen} onClose={() => setProfileModalOpen(false)} />
</div>
);
}
+729
View File
@@ -0,0 +1,729 @@
import { useCallback, useRef, useEffect, useState } from 'react';
import { Dropdown, Tooltip, App, theme, Popover, Divider, Typography, Space, Spin } from 'antd';
import type { MenuProps } from 'antd';
import { Settings, XCircle, Sun, Moon, Monitor, Globe, Pin, PinOff, RotateCcw, CloudUpload, Github, Star, MessageSquarePlus, Bug, ArrowDownCircle, Minus, X, Square } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useUIStore, useSettingsStore } from '@/stores';
import { useBackupStore } from '@/stores/backupStore';
import { isTauri, invoke } from '@/lib/invoke';
import { getShortcutBinding, formatShortcutForDisplay } from '@/lib/shortcuts';
import { useUpdateChecker } from '@/hooks/useUpdateChecker';
import appLogo from '@/assets/image/logo.png';
import { getCurrentWindow } from '@tauri-apps/api/window';
const IS_WINDOWS = navigator.userAgent.includes('Windows');
/** Standard Windows "restore down" icon: two overlapping rectangles */
const RestoreIcon = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="3" y="5" width="8" height="7" rx="0.5" />
<path d="M5 5V3.5a.5.5 0 0 1 .5-.5H12a.5.5 0 0 1 .5.5V10a.5.5 0 0 1-.5.5h-1.5" />
</svg>
);
const THEME_OPTIONS = [
{ key: 'system', icon: <Monitor size={14} />, labelKey: 'settings.themeSystem' },
{ key: 'light', icon: <Sun size={14} />, labelKey: 'settings.themeLight' },
{ key: 'dark', icon: <Moon size={14} />, labelKey: 'settings.themeDark' },
] as const;
const THEME_ICONS: Record<string, React.ReactNode> = {
system: <Monitor size={14} />,
light: <Sun size={14} />,
dark: <Moon size={14} />,
};
import { LANG_OPTIONS } from '@/lib/constants';
export function TitleBar() {
const { t, i18n } = useTranslation();
const { token } = theme.useToken();
const { modal, message } = App.useApp();
const activePage = useUIStore((s) => s.activePage);
const enterSettings = useUIStore((s) => s.enterSettings);
const exitSettings = useUIStore((s) => s.exitSettings);
const themeMode = useSettingsStore((s) => s.settings.theme_mode);
const alwaysOnTop = useSettingsStore((s) => s.settings.always_on_top);
const saveSettings = useSettingsStore((s) => s.saveSettings);
const settings = useSettingsStore((s) => s.settings);
const isInSettings = activePage === 'settings';
const [pinned, setPinned] = useState(alwaysOnTop ?? false);
useEffect(() => {
setPinned(alwaysOnTop ?? false);
}, [alwaysOnTop]);
const handlePinToggle = useCallback(async () => {
const next = !pinned;
setPinned(next);
try {
await invoke('set_always_on_top', { enabled: next });
saveSettings({ always_on_top: next });
} catch {
setPinned(!next);
}
}, [pinned, saveSettings]);
const { checkForUpdate } = useUpdateChecker();
const [checkingUpdate, setCheckingUpdate] = useState(false);
const handleCheckUpdate = useCallback(async () => {
setCheckingUpdate(true);
try {
await checkForUpdate();
} finally {
setCheckingUpdate(false);
}
}, [checkForUpdate]);
const themeMenuItems: MenuProps['items'] = THEME_OPTIONS.map((opt) => ({
key: opt.key,
icon: opt.icon,
label: t(opt.labelKey),
}));
const langMenuItems: MenuProps['items'] = LANG_OPTIONS.map((opt) => ({
key: opt.key,
icon: <span>{opt.icon}</span>,
label: opt.label,
}));
const handleThemeChange: MenuProps['onClick'] = ({ key }) => {
saveSettings({ theme_mode: key });
};
const handleLangChange: MenuProps['onClick'] = ({ key }) => {
i18n.changeLanguage(key);
saveSettings({ language: key });
};
const handleSettingsToggle = () => {
if (isInSettings) {
exitSettings();
} else {
enterSettings();
}
};
const handleReload = useCallback(() => {
modal.confirm({
title: t('desktop.reloadConfirmTitle'),
content: t('desktop.reloadConfirmContent'),
okText: t('desktop.reloadConfirmOk'),
cancelText: t('desktop.reloadConfirmCancel'),
onOk: () => {
window.location.reload();
},
});
}, [modal, t]);
// Windows window controls
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
if (!IS_WINDOWS || !isTauri()) return;
let unlisten: (() => void) | undefined;
(async () => {
const win = getCurrentWindow();
setIsMaximized(await win.isMaximized());
unlisten = await win.onResized(async () => {
setIsMaximized(await win.isMaximized());
});
})();
return () => { unlisten?.(); };
}, []);
const handleWindowMinimize = useCallback(async () => {
await invoke('minimize_window');
}, []);
const handleWindowMaximize = useCallback(async () => {
await invoke('toggle_maximize_window');
}, []);
const handleWindowClose = useCallback(async () => {
await getCurrentWindow().close();
}, []);
// Quick Backup state
const [backupPopoverOpen, setBackupPopoverOpen] = useState(false);
const [backingUp, setBackingUp] = useState<'local' | 'webdav' | null>(null);
const [lastLocalBackup, setLastLocalBackup] = useState<string | null>(null);
const [lastWebDavSync, setLastWebDavSync] = useState<string | null>(null);
// Timestamps (ms) for next scheduled backups
const [nextLocalTs, setNextLocalTs] = useState<number | null>(null);
const [nextWebDavTs, setNextWebDavTs] = useState<number | null>(null);
// Live countdown strings (updated every second)
const [countdownText, setCountdownText] = useState<string | null>(null);
const [popoverLocalCountdown, setPopoverLocalCountdown] = useState<string | null>(null);
const [popoverWebDavCountdown, setPopoverWebDavCountdown] = useState<string | null>(null);
const { backupSettings, loadBackupSettings } = useBackupStore();
const fmtCountdown = (ms: number) => {
if (ms <= 0) return t('titlebar.now');
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
};
// Fetch backup info on mount and when popover opens
useEffect(() => {
loadBackupSettings();
invoke<{ lastSyncTime: string | null }>('get_webdav_sync_status')
.then((s) => {
if (s.lastSyncTime) {
const d = new Date(s.lastSyncTime);
if (!Number.isNaN(d.getTime())) {
setLastWebDavSync(d.toLocaleString());
}
}
})
.catch(() => {});
invoke<Array<{ createdAt: string }>>('list_backups')
.then((list) => {
if (list.length > 0) {
const raw = list[0].createdAt;
const d = new Date(raw.includes('T') || raw.includes('Z') ? raw : raw + 'Z');
if (!Number.isNaN(d.getTime())) setLastLocalBackup(d.toLocaleString());
}
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [backupPopoverOpen]);
// Calculate next WebDAV sync timestamp (re-run when settings or lastWebDavSync change)
useEffect(() => {
if (!lastWebDavSync) {
setNextWebDavTs(null);
return;
}
const d = new Date(lastWebDavSync);
if (Number.isNaN(d.getTime())) return;
const interval = settings.webdav_sync_interval_minutes ?? 60;
if (settings.webdav_sync_enabled && interval > 0) {
const intervalMs = interval * 60000;
let next = d.getTime() + intervalMs;
// If overdue, advance to the next future interval
while (next < Date.now()) {
next += intervalMs;
}
setNextWebDavTs(next);
} else {
setNextWebDavTs(null);
}
}, [settings.webdav_sync_enabled, settings.webdav_sync_interval_minutes, lastWebDavSync]);
// Calculate next local backup timestamp from backup settings
useEffect(() => {
if (!backupSettings?.enabled) {
setNextLocalTs(null);
return;
}
const intervalMs = (backupSettings.intervalHours ?? 24) * 3600000;
if (lastLocalBackup) {
const lastTime = new Date(lastLocalBackup).getTime();
if (!Number.isNaN(lastTime)) {
let next = lastTime + intervalMs;
// If overdue, advance to the next future interval
while (next < Date.now()) {
next += intervalMs;
}
setNextLocalTs(next);
return;
}
}
// No previous backup — next backup at now + interval
setNextLocalTs(Date.now() + intervalMs);
}, [backupSettings, lastLocalBackup]);
// Live countdown on button — tick every second while any backup is scheduled
useEffect(() => {
const tick = () => {
const now = Date.now();
let soonest: number | null = null;
if (nextLocalTs) {
if (!soonest || nextLocalTs < soonest) soonest = nextLocalTs;
}
if (nextWebDavTs) {
if (!soonest || nextWebDavTs < soonest) soonest = nextWebDavTs;
}
if (soonest) {
setCountdownText(fmtCountdown(soonest - now));
} else {
setCountdownText(null);
}
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [nextLocalTs, nextWebDavTs]);
// Live countdown in popover — tick every second only when open
useEffect(() => {
if (!backupPopoverOpen) return;
const tick = () => {
const now = Date.now();
if (nextLocalTs && nextLocalTs > now) {
setPopoverLocalCountdown(`${new Date(nextLocalTs).toLocaleString()} (${fmtCountdown(nextLocalTs - now)})`);
} else {
setPopoverLocalCountdown(null);
}
if (nextWebDavTs && nextWebDavTs > now) {
setPopoverWebDavCountdown(`${new Date(nextWebDavTs).toLocaleString()} (${fmtCountdown(nextWebDavTs - now)})`);
} else {
setPopoverWebDavCountdown(null);
}
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [backupPopoverOpen, nextLocalTs, nextWebDavTs]);
const handleQuickBackup = useCallback(async (type: 'local' | 'webdav') => {
setBackingUp(type);
try {
if (type === 'local') {
await invoke('create_backup', { format: 'sqlite' });
} else {
await invoke('webdav_backup');
}
message.success(t('backup.backupSuccess'));
setBackupPopoverOpen(false);
} catch (e) {
message.error(String(e));
} finally {
setBackingUp(null);
}
}, [message, t]);
const GITHUB_REPO = 'https://github.com/Boos4721/boos-chat';
const githubMenuItems: MenuProps['items'] = [
{
key: 'feature',
icon: <MessageSquarePlus size={14} />,
label: t('titlebar.submitFeature'),
},
{
key: 'bug',
icon: <Bug size={14} />,
label: t('titlebar.submitBug'),
},
{ type: 'divider' },
{
key: 'star',
icon: <Star size={14} />,
label: t('titlebar.giveStar'),
},
];
const handleGithubClick: MenuProps['onClick'] = ({ key }) => {
let url = GITHUB_REPO;
if (key === 'feature') url = `${GITHUB_REPO}/issues/new?labels=enhancement&template=feature_request.yml`;
else if (key === 'bug') url = `${GITHUB_REPO}/issues/new?labels=bug&template=bug_report.yml`;
if (isTauri()) {
import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(url)).catch(() => window.open(url, '_blank'));
} else {
window.open(url, '_blank');
}
};
const dragTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleDragMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.closest('button')) return;
if (!isTauri()) return;
e.preventDefault();
if (IS_WINDOWS) {
// Delay startDragging slightly so double-click can be detected.
// If a second mousedown arrives within the threshold,
// the onDoubleClick handler fires and cancels the pending drag.
if (dragTimerRef.current) clearTimeout(dragTimerRef.current);
dragTimerRef.current = setTimeout(() => {
getCurrentWindow().startDragging();
}, 200);
} else {
getCurrentWindow().startDragging();
}
}, []);
const handleTitleBarDoubleClick = useCallback(() => {
if (!IS_WINDOWS) return;
if (dragTimerRef.current) {
clearTimeout(dragTimerRef.current);
dragTimerRef.current = null;
}
invoke('toggle_maximize_window');
}, []);
const buttonBase: React.CSSProperties = {
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: token.borderRadius,
fontSize: 14,
cursor: 'pointer',
border: 'none',
backgroundColor: 'transparent',
};
const hoverHandlers = (baseColor: string) => ({
onMouseEnter: (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.style.backgroundColor = token.colorFillSecondary;
e.currentTarget.style.color = token.colorTextBase;
},
onMouseLeave: (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = baseColor;
},
});
return (
<div
className="title-bar-drag"
{...(!IS_WINDOWS ? { 'data-tauri-drag-region': true } : {})}
onMouseDown={handleDragMouseDown}
onDoubleClick={IS_WINDOWS ? handleTitleBarDoubleClick : undefined}
style={{
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingLeft: IS_WINDOWS ? 12 : 72,
paddingRight: IS_WINDOWS ? 0 : 12,
backgroundColor: 'transparent',
flexShrink: 0,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
{/* Left: App icon + name (Windows only) */}
{IS_WINDOWS ? (
<div className="title-bar-nodrag" style={{ display: 'flex', alignItems: 'center', gap: 6, marginRight: 8 }}>
<img src={appLogo} alt="BoosAPI Desktop" style={{ width: 18, height: 18 }} draggable={false} />
<span style={{ fontSize: 13, fontWeight: 600, color: token.colorTextBase, userSelect: 'none' }}>BoosAPI Desktop</span>
</div>
) : <div />}
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
<div className="title-bar-nodrag" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{/* Pin Toggle */}
<Tooltip title={t('desktop.alwaysOnTop')}>
<button
onClick={handlePinToggle}
style={{
...buttonBase,
color: pinned ? token.colorPrimary : token.colorTextSecondary,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = pinned
? token.colorPrimaryBg
: token.colorFillSecondary;
e.currentTarget.style.color = pinned
? token.colorPrimary
: token.colorTextBase;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = pinned
? token.colorPrimary
: token.colorTextSecondary;
}}
>
{pinned ? <Pin size={14} /> : <PinOff size={14} />}
</button>
</Tooltip>
{/* Theme Dropdown */}
<Dropdown
menu={{ items: themeMenuItems, onClick: handleThemeChange, selectedKeys: [themeMode] }}
trigger={['click']}
placement="bottomRight"
destroyOnHidden
>
<button
style={{ ...buttonBase, color: token.colorTextSecondary }}
{...hoverHandlers(token.colorTextSecondary)}
>
{THEME_ICONS[themeMode] ?? <Monitor size={14} />}
</button>
</Dropdown>
{/* Language Dropdown */}
<Dropdown
menu={{ items: langMenuItems, onClick: handleLangChange, selectedKeys: [i18n.language] }}
trigger={['click']}
placement="bottomRight"
destroyOnHidden
>
<button
style={{ ...buttonBase, color: token.colorTextSecondary }}
{...hoverHandlers(token.colorTextSecondary)}
>
<Globe size={14} />
</button>
</Dropdown>
{/* Quick Backup */}
<Popover
open={backupPopoverOpen}
onOpenChange={setBackupPopoverOpen}
trigger="click"
placement="bottomRight"
destroyTooltipOnHide
content={
<div style={{ width: 240 }}>
<Typography.Text strong style={{ fontSize: 13 }}>
{t('titlebar.lastBackup')}
</Typography.Text>
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 4 }}>
{lastLocalBackup && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('titlebar.lastLocal')}: {lastLocalBackup}
</Typography.Text>
)}
{lastWebDavSync && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
WebDAV: {lastWebDavSync}
</Typography.Text>
)}
{!lastLocalBackup && !lastWebDavSync && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('titlebar.noBackupYet')}
</Typography.Text>
)}
</Space>
{(popoverLocalCountdown || popoverWebDavCountdown) && (
<>
<Divider style={{ margin: '6px 0' }} />
<Typography.Text strong style={{ fontSize: 13 }}>
{t('titlebar.nextBackup')}
</Typography.Text>
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 4 }}>
{popoverLocalCountdown && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('titlebar.lastLocal')}: {popoverLocalCountdown}
</Typography.Text>
)}
{popoverWebDavCountdown && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
WebDAV: {popoverWebDavCountdown}
</Typography.Text>
)}
</Space>
</>
)}
<Divider style={{ margin: '6px 0' }} />
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<button
onClick={() => handleQuickBackup('local')}
disabled={backingUp !== null}
style={{
width: '100%',
padding: '4px 8px',
borderRadius: token.borderRadius,
border: `1px solid ${token.colorBorder}`,
backgroundColor: 'transparent',
cursor: backingUp ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: 6,
color: token.colorText,
}}
>
{backingUp === 'local' ? <Spin size="small" /> : <CloudUpload size={14} />}
{t('titlebar.localBackup')}
</button>
<button
onClick={() => handleQuickBackup('webdav')}
disabled={backingUp !== null}
style={{
width: '100%',
padding: '4px 8px',
borderRadius: token.borderRadius,
border: `1px solid ${token.colorBorder}`,
backgroundColor: 'transparent',
cursor: backingUp ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: 6,
color: token.colorText,
}}
>
{backingUp === 'webdav' ? <Spin size="small" /> : <CloudUpload size={14} />}
{t('titlebar.webdavBackup')}
</button>
</Space>
</div>
}
>
<Tooltip title={t('titlebar.quickBackup')}>
<button
style={{
...buttonBase,
color: countdownText ? token.colorPrimary : token.colorTextSecondary,
width: countdownText ? 'auto' : 28,
paddingInline: countdownText ? 4 : 0,
gap: 2,
fontSize: 11,
}}
{...hoverHandlers(countdownText ? token.colorPrimary : token.colorTextSecondary)}
>
<CloudUpload size={14} />
{countdownText && <span>({countdownText})</span>}
</button>
</Tooltip>
</Popover>
{/* GitHub */}
<Dropdown
menu={{ items: githubMenuItems, onClick: handleGithubClick }}
trigger={['click']}
placement="bottomRight"
destroyOnHidden
>
<button
style={{ ...buttonBase, color: token.colorTextSecondary }}
{...hoverHandlers(token.colorTextSecondary)}
>
<Github size={14} />
</button>
</Dropdown>
{/* Check Update */}
{isTauri() && (
<Tooltip title={t('settings.checkUpdate')}>
<button
onClick={handleCheckUpdate}
disabled={checkingUpdate}
style={{ ...buttonBase, color: token.colorTextSecondary, opacity: checkingUpdate ? 0.5 : 1 }}
{...hoverHandlers(token.colorTextSecondary)}
>
{checkingUpdate ? <Spin size="small" /> : <ArrowDownCircle size={14} />}
</button>
</Tooltip>
)}
{/* Reload Page */}
<Tooltip title={t('desktop.reloadPage')}>
<button
onClick={handleReload}
style={{ ...buttonBase, color: token.colorTextSecondary }}
{...hoverHandlers(token.colorTextSecondary)}
>
<RotateCcw size={14} />
</button>
</Tooltip>
{/* Settings Toggle */}
<Tooltip title={`${isInSettings ? t('settings.closeSettings') : t('settings.openSettings')} (${formatShortcutForDisplay(getShortcutBinding(settings, 'openSettings'))})`}>
<button
onClick={(e) => {
handleSettingsToggle();
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.blur();
}}
style={{
...buttonBase,
color: isInSettings ? token.colorError : token.colorTextSecondary,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = isInSettings
? token.colorErrorBg
: token.colorFillSecondary;
e.currentTarget.style.color = isInSettings
? token.colorError
: token.colorTextBase;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = isInSettings
? token.colorError
: token.colorTextSecondary;
}}
>
{isInSettings ? <XCircle size={14} /> : <Settings size={14} />}
</button>
</Tooltip>
</div>
{/* Windows window controls */}
{IS_WINDOWS && isTauri() && (
<div className="title-bar-nodrag" style={{ display: 'flex', alignItems: 'center', marginLeft: 4 }}>
{/* Minimize */}
<button
onClick={handleWindowMinimize}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 46,
height: 36,
border: 'none',
background: 'transparent',
color: token.colorTextSecondary,
cursor: 'pointer',
outline: 'none',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = token.colorFillSecondary; e.currentTarget.style.color = token.colorTextBase; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = token.colorTextSecondary; }}
>
<Minus size={16} />
</button>
{/* Maximize / Restore */}
<button
onClick={handleWindowMaximize}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 46,
height: 36,
border: 'none',
background: 'transparent',
color: token.colorTextSecondary,
cursor: 'pointer',
outline: 'none',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = token.colorFillSecondary; e.currentTarget.style.color = token.colorTextBase; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = token.colorTextSecondary; }}
>
{isMaximized ? <RestoreIcon /> : <Square size={14} />}
</button>
{/* Close */}
<button
onClick={handleWindowClose}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 46,
height: 36,
border: 'none',
background: 'transparent',
color: token.colorTextSecondary,
cursor: 'pointer',
outline: 'none',
borderRadius: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#e81123'; e.currentTarget.style.color = '#ffffff'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = token.colorTextSecondary; }}
>
<X size={16} />
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import { useState } from 'react';
import { Modal, Input, Avatar, theme } from 'antd';
import { User } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useUserProfileStore } from '@/stores/userProfileStore';
import type { AvatarType } from '@/stores/userProfileStore';
import { IconEditor } from '@/components/shared/IconEditor';
interface UserProfileModalProps {
open: boolean;
onClose: () => void;
}
export function UserProfileModal({ open, onClose }: UserProfileModalProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const profile = useUserProfileStore((s) => s.profile);
const updateProfile = useUserProfileStore((s) => s.updateProfile);
const [name, setName] = useState(profile.name);
const [avatarType, setAvatarType] = useState<AvatarType>(profile.avatarType);
const [avatarValue, setAvatarValue] = useState(profile.avatarValue);
const handleSave = () => {
updateProfile({ name: name.trim(), avatarType, avatarValue });
onClose();
};
return (
<Modal
open={open}
onCancel={onClose}
mask={{ enabled: true, blur: true }}
onOk={handleSave}
title={t('userProfile.title')}
width={400}
destroyOnHidden
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: '16px 0' }}>
<IconEditor
iconType={avatarType}
iconValue={avatarValue}
onChange={(type, value) => {
setAvatarType((type as AvatarType) ?? 'icon');
setAvatarValue(value ?? '');
}}
size={72}
defaultIcon={
<Avatar
size={72}
icon={<User size={16} />}
style={{ cursor: 'pointer', backgroundColor: token.colorPrimary }}
/>
}
showClear={false}
/>
{/* Name input */}
<Input
placeholder={t('userProfile.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
style={{ maxWidth: 280 }}
/>
</div>
</Modal>
);
}
@@ -0,0 +1,86 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GlobalCopyMenu } from '../GlobalCopyMenu';
const mocks = vi.hoisted(() => ({
loadDevtoolsContextMenuEnabled: vi.fn(),
openDevtoolsWithDiagnostics: vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const labels: Record<string, string> = {
'common.openDevtools': 'Open DevTools',
'common.copy': 'Copy',
'common.copySuccess': 'Copied',
};
return labels[key] ?? key;
},
}),
}));
vi.mock('antd', () => ({
message: {
success: vi.fn(),
},
theme: {
useToken: () => ({
token: {
colorBgElevated: '#ffffff',
colorBorderSecondary: '#eeeeee',
colorText: '#111111',
colorTextDisabled: '#999999',
boxShadowSecondary: 'none',
},
}),
},
}));
vi.mock('@/stores', () => ({
useConversationStore: (selector: (state: { activeConversationId: string | null }) => unknown) => (
selector({ activeConversationId: null })
),
}));
vi.mock('@/lib/desktopCapabilities', () => ({
loadDevtoolsContextMenuEnabled: mocks.loadDevtoolsContextMenuEnabled,
openDevtoolsWithDiagnostics: mocks.openDevtoolsWithDiagnostics,
}));
describe('GlobalCopyMenu', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadDevtoolsContextMenuEnabled.mockResolvedValue(false);
});
it('does not show devtools in release mode when diagnostic capability is disabled', async () => {
render(<GlobalCopyMenu />);
await waitFor(() => expect(mocks.loadDevtoolsContextMenuEnabled).toHaveBeenCalled());
fireEvent.contextMenu(document.body, { clientX: 20, clientY: 20 });
expect(screen.queryByText('Open DevTools')).not.toBeInTheDocument();
});
it('shows devtools in release mode when diagnostic capability is enabled', async () => {
mocks.loadDevtoolsContextMenuEnabled.mockResolvedValue(true);
render(<GlobalCopyMenu />);
await waitFor(() => expect(mocks.loadDevtoolsContextMenuEnabled).toHaveBeenCalled());
fireEvent.contextMenu(document.body, { clientX: 20, clientY: 20 });
expect(await screen.findByText('Open DevTools')).toBeInTheDocument();
});
it('opens devtools through diagnostic wrapper', async () => {
mocks.loadDevtoolsContextMenuEnabled.mockResolvedValue(true);
render(<GlobalCopyMenu />);
await waitFor(() => expect(mocks.loadDevtoolsContextMenuEnabled).toHaveBeenCalled());
fireEvent.contextMenu(document.body, { clientX: 20, clientY: 20 });
fireEvent.click(await screen.findByText('Open DevTools'));
expect(mocks.openDevtoolsWithDiagnostics).toHaveBeenCalled();
});
});
+3
View File
@@ -0,0 +1,3 @@
export { ContentArea } from './ContentArea';
export { Sidebar } from './Sidebar';
export { TitleBar } from './TitleBar';