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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ContentArea } from './ContentArea';
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { TitleBar } from './TitleBar';
|
||||
Reference in New Issue
Block a user