feat(ui): 完成新版 UI 全量改造

- 整体布局:按新版 UI 重构左侧导航、对象树、连接分组和右键菜单体系

- 数据视图:优化 DDL 侧栏、横向滚动、筛选输入、编辑入口和虚拟表格体验

- AI 面板:重构新版入口、输入区、模型选择、快捷键和悬浮布局

- 标签与快捷键:补齐 Tab 悬浮信息、复制交互和 Mac/Windows 快捷键配置

- 工程质量:新增 v2 主题样式、菜单组件、外观工具和回归测试覆盖
This commit is contained in:
Syngnat
2026-05-22 17:38:00 +08:00
parent 1d90aed187
commit 24d9db4c51
35 changed files with 11121 additions and 742 deletions

View File

@@ -5,7 +5,7 @@ import {
DEFAULT_SHORTCUT_OPTIONS,
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
type ShortcutBinding,
type ShortcutPlatformBinding,
} from './shortcuts';
import {
consumeAIChatSendShortcutOnKeyDown,
@@ -13,12 +13,15 @@ import {
shouldSendAIChatOnKeyDown,
} from './aiChatSendShortcut';
const binding = (combo: string, enabled = true): ShortcutBinding => ({ combo, enabled });
const binding = (combo: string, enabled = true): ShortcutPlatformBinding => ({ combo, enabled });
describe('aiChatSendShortcut', () => {
it('registers AI chat send in the shared shortcut center with Enter default', () => {
expect(SHORTCUT_ACTION_ORDER).toContain('sendAIChatMessage');
expect(DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage).toEqual({ combo: 'Enter', enabled: true });
expect(DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage).toEqual({
mac: { combo: 'Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },
});
expect(SHORTCUT_ACTION_META.sendAIChatMessage).toMatchObject({
label: 'AI 聊天发送',
allowInEditable: true,
@@ -71,6 +74,7 @@ describe('aiChatSendShortcut', () => {
it('does not allow Shift to become an AI send shortcut even if a stale binding exists', () => {
expect(shouldSendAIChatOnKeyDown(binding('Shift+Enter'), { key: 'Enter', shiftKey: true })).toBe(false);
expect(getAIChatSendShortcutLabel(binding('Meta+Enter'))).toBe('Meta+Enter 发送');
expect(getAIChatSendShortcutLabel(binding('Meta+Enter'), 'mac')).toBe('⌘↵ 发送');
expect(getAIChatSendShortcutLabel(binding('Enter', false))).toBe('快捷键发送已关闭');
});

View File

@@ -1,4 +1,4 @@
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplay, isShortcutMatch, type ShortcutBinding } from './shortcuts';
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplayLabel, isShortcutMatch, type ShortcutPlatform, type ShortcutPlatformBinding } from './shortcuts';
export interface AIChatSendShortcutKeyEventLike {
key?: string;
@@ -18,16 +18,19 @@ export interface AIChatSendShortcutKeyEventLike {
stopPropagation?: () => void;
}
export const getAIChatSendShortcutLabel = (binding: ShortcutBinding | undefined): string => {
export const getAIChatSendShortcutLabel = (
binding: ShortcutPlatformBinding | undefined,
platform: ShortcutPlatform = 'windows',
): string => {
if (binding?.enabled === false) {
return '快捷键发送已关闭';
}
const combo = binding?.combo || DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage.combo;
return `${getShortcutDisplay(combo)} 发送`;
const combo = binding?.combo || DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage.windows.combo;
return `${getShortcutDisplayLabel(combo, platform)} 发送`;
};
export const shouldSendAIChatOnKeyDown = (
binding: ShortcutBinding | undefined,
binding: ShortcutPlatformBinding | undefined,
event: AIChatSendShortcutKeyEventLike,
): boolean => {
if (!binding?.enabled) {
@@ -45,7 +48,7 @@ export const shouldSendAIChatOnKeyDown = (
};
export const consumeAIChatSendShortcutOnKeyDown = (
binding: ShortcutBinding | undefined,
binding: ShortcutPlatformBinding | undefined,
event: AIChatSendShortcutKeyEventLike,
onSend: () => void,
): boolean => {

View File

@@ -1,31 +1,26 @@
import { describe, expect, it } from 'vitest';
import {
SIDEBAR_UTILITY_ITEM_KEYS,
resolveAIEntryPlacement,
resolveAIEdgeHandleAttachment,
resolveAIEdgeHandleDockStyle,
resolveAIEdgeHandleStyle,
resolveLegacyAIEdgeHandleAttachment,
resolveLegacyAIEdgeHandleDockStyle,
resolveLegacyAIEdgeHandleStyle,
} from './aiEntryLayout';
describe('ai entry layout', () => {
it('keeps the sidebar utility group compact and free of the AI entry', () => {
it('keeps legacy sidebar utility buttons limited to tools and settings', () => {
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'settings']);
});
it('anchors the AI entry to the content edge', () => {
expect(resolveAIEntryPlacement()).toBe('content-edge');
it('attaches the legacy closed AI handle to the content shell', () => {
expect(resolveLegacyAIEdgeHandleAttachment(false)).toBe('content-shell');
});
it('attaches the closed handle to the content shell', () => {
expect(resolveAIEdgeHandleAttachment(false)).toBe('content-shell');
it('attaches the legacy open AI handle to the panel shell', () => {
expect(resolveLegacyAIEdgeHandleAttachment(true)).toBe('panel-shell');
});
it('attaches the open handle to the panel shell', () => {
expect(resolveAIEdgeHandleAttachment(true)).toBe('panel-shell');
});
it('keeps the closed handle docked on the content edge', () => {
expect(resolveAIEdgeHandleDockStyle('content-shell')).toMatchObject({
it('keeps the legacy closed handle docked on the content edge', () => {
expect(resolveLegacyAIEdgeHandleDockStyle('content-shell')).toMatchObject({
position: 'absolute',
top: 16,
right: 0,
@@ -33,8 +28,8 @@ describe('ai entry layout', () => {
});
});
it('keeps the open handle outside the panel shell to avoid header overlap', () => {
expect(resolveAIEdgeHandleDockStyle('panel-shell')).toMatchObject({
it('keeps the legacy open handle outside the panel shell to avoid header overlap', () => {
expect(resolveLegacyAIEdgeHandleDockStyle('panel-shell')).toMatchObject({
position: 'absolute',
top: 16,
right: '100%',
@@ -42,8 +37,8 @@ describe('ai entry layout', () => {
});
});
it('uses the attached active appearance when the AI panel is open', () => {
const style = resolveAIEdgeHandleStyle({
it('uses the attached active appearance when the legacy AI panel is open', () => {
const style = resolveLegacyAIEdgeHandleStyle({
darkMode: true,
aiPanelVisible: true,
effectiveUiScale: 1,
@@ -55,17 +50,4 @@ describe('ai entry layout', () => {
expect(style.borderRight).toBe('none');
expect(style.height).toBe(24);
});
it('uses the subdued attached appearance when the AI panel is closed', () => {
const style = resolveAIEdgeHandleStyle({
darkMode: false,
aiPanelVisible: false,
effectiveUiScale: 1,
});
expect(style.color).toBe('rgba(22,32,51,0.82)');
expect(style.background).toBe('rgba(15,23,42,0.04)');
expect(style.paddingInline).toBe(8);
expect(style.borderRadius).toBe('10px 0 0 10px');
});
});

View File

@@ -2,23 +2,20 @@ import type { CSSProperties } from 'react';
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'settings'] as const;
export type AIEntryPlacement = 'content-edge';
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';
export type LegacyAIEdgeHandleAttachment = 'content-shell' | 'panel-shell';
export interface ResolveAIEdgeHandleStyleInput {
export interface ResolveLegacyAIEdgeHandleStyleInput {
darkMode: boolean;
aiPanelVisible: boolean;
effectiveUiScale: number;
}
export const resolveAIEntryPlacement = (): AIEntryPlacement => 'content-edge';
export const resolveAIEdgeHandleAttachment = (
export const resolveLegacyAIEdgeHandleAttachment = (
aiPanelVisible: boolean,
): AIEdgeHandleAttachment => (aiPanelVisible ? 'panel-shell' : 'content-shell');
): LegacyAIEdgeHandleAttachment => (aiPanelVisible ? 'panel-shell' : 'content-shell');
export const resolveAIEdgeHandleDockStyle = (
attachment: AIEdgeHandleAttachment,
export const resolveLegacyAIEdgeHandleDockStyle = (
attachment: LegacyAIEdgeHandleAttachment,
): CSSProperties => ({
position: 'absolute',
top: 16,
@@ -26,11 +23,11 @@ export const resolveAIEdgeHandleDockStyle = (
zIndex: 12,
});
export const resolveAIEdgeHandleStyle = ({
export const resolveLegacyAIEdgeHandleStyle = ({
darkMode,
aiPanelVisible,
effectiveUiScale,
}: ResolveAIEdgeHandleStyleInput): CSSProperties => {
}: ResolveLegacyAIEdgeHandleStyleInput): CSSProperties => {
const inactiveColor = darkMode ? 'rgba(255,255,255,0.86)' : 'rgba(22,32,51,0.82)';
const inactiveBackground = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(15,23,42,0.04)';
const inactiveBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)';

View File

@@ -3,12 +3,24 @@ export type DataTableDensity = 'comfortable' | 'standard' | 'compact';
export interface DataGridDisplaySettings {
showDataTableVerticalBorders: boolean;
dataTableDensity: DataTableDensity;
dataTableFontSize: number | null;
dataTableFontSizeFollowGlobal: boolean;
sidebarTreeFontSize: number | null;
sidebarTreeFontSizeFollowGlobal: boolean;
}
export const DEFAULT_DATA_GRID_DISPLAY_SETTINGS: DataGridDisplaySettings = {
showDataTableVerticalBorders: false,
dataTableDensity: 'comfortable',
dataTableFontSize: null,
dataTableFontSizeFollowGlobal: true,
sidebarTreeFontSize: null,
sidebarTreeFontSizeFollowGlobal: true,
};
export const MIN_DATA_TABLE_FONT_SIZE = 10;
export const MAX_DATA_TABLE_FONT_SIZE = 18;
export const MIN_SIDEBAR_TREE_FONT_SIZE = 10;
export const MAX_SIDEBAR_TREE_FONT_SIZE = 18;
interface DensityParams {
defaultColumnWidth: number;
@@ -57,6 +69,25 @@ export const sanitizeDataTableDensity = (value: unknown): DataTableDensity => {
return 'comfortable';
};
const sanitizeOptionalIntegerInRange = (
value: unknown,
min: number,
max: number,
): number | null => {
if (value === null || value === undefined || value === '') return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return Math.min(max, Math.max(min, Math.round(numeric)));
};
export const sanitizeDataTableFontSize = (value: unknown): number | null => (
sanitizeOptionalIntegerInRange(value, MIN_DATA_TABLE_FONT_SIZE, MAX_DATA_TABLE_FONT_SIZE)
);
export const sanitizeSidebarTreeFontSize = (value: unknown): number | null => (
sanitizeOptionalIntegerInRange(value, MIN_SIDEBAR_TREE_FONT_SIZE, MAX_SIDEBAR_TREE_FONT_SIZE)
);
export const getDensityParams = (density: DataTableDensity): DensityParams => {
return DENSITY_PARAMS[density] || DENSITY_PARAMS.comfortable;
};
@@ -68,9 +99,20 @@ export const sanitizeDataGridDisplaySettings = (
return { ...DEFAULT_DATA_GRID_DISPLAY_SETTINGS };
}
const dataTableFontSize = sanitizeDataTableFontSize(value.dataTableFontSize);
const sidebarTreeFontSize = sanitizeSidebarTreeFontSize(value.sidebarTreeFontSize);
return {
showDataTableVerticalBorders: value.showDataTableVerticalBorders === true,
dataTableDensity: sanitizeDataTableDensity(value.dataTableDensity),
dataTableFontSize,
dataTableFontSizeFollowGlobal: typeof value.dataTableFontSizeFollowGlobal === 'boolean'
? value.dataTableFontSizeFollowGlobal
: dataTableFontSize === null,
sidebarTreeFontSize,
sidebarTreeFontSizeFollowGlobal: typeof value.sidebarTreeFontSizeFollowGlobal === 'boolean'
? value.sidebarTreeFontSizeFollowGlobal
: sidebarTreeFontSize === null,
};
};

View File

@@ -42,6 +42,10 @@ const WHERE_OPERATORS = [
'IS NOT NULL',
];
const WHERE_CONTINUATION_KEYWORDS = ['AND', 'OR'];
const WHERE_OPERATOR_CONTEXT_PATTERN = /\bNOT\s+LIKE\b|\bIS\s+NOT\s+NULL\b|\bIS\s+NULL\b|\bIS\s+NOT\b|\bBETWEEN\b|\bLIKE\b|\bIN\b|>=|<=|<>|!=|=|>|</gi;
const WHERE_COMPLETE_SUFFIX_OPERATOR_PATTERN = /\bIS\s+(?:NOT\s+)?NULL\s*$/i;
const toTrimmedString = (value: unknown): string => {
if (typeof value === 'string') {
return value.trim();
@@ -90,6 +94,35 @@ const shouldSuggestOperators = (input: string): boolean => {
return /\s$/.test(input) && /(?:[A-Za-z_][A-Za-z0-9_$]*|"[^"]+"|`[^`]+`)\s$/.test(input);
};
const isLogicalConnectorCursor = (value: string): boolean => {
return /(?:^|[\s(])(?:AND|OR|NOT)\s*$/i.test(String(value || ''));
};
const getLastWhereOperatorMatch = (value: string): RegExpMatchArray | null => {
const matches = [...String(value || '').matchAll(WHERE_OPERATOR_CONTEXT_PATTERN)];
return matches.length > 0 ? matches[matches.length - 1] : null;
};
const isAwaitingValueAfterOperator = (value: string): boolean => {
const text = String(value || '');
if (isLogicalConnectorCursor(text)) return false;
const operatorMatch = getLastWhereOperatorMatch(text);
if (!operatorMatch || operatorMatch.index === undefined) return false;
const afterOperator = text.slice(operatorMatch.index + operatorMatch[0].length);
return !/\S/.test(afterOperator);
};
const hasCompletedPredicateBeforeCursor = (value: string): boolean => {
const text = String(value || '');
if (isLogicalConnectorCursor(text)) return false;
if (WHERE_COMPLETE_SUFFIX_OPERATOR_PATTERN.test(text)) return true;
const operatorMatch = getLastWhereOperatorMatch(text);
if (!operatorMatch || operatorMatch.index === undefined) return false;
const afterOperator = text.slice(operatorMatch.index + operatorMatch[0].length);
return /\S/.test(afterOperator);
};
const toOperatorInsertText = (operator: string): string => {
if (operator === 'IN') return 'IN ()';
if (operator === 'BETWEEN') return 'BETWEEN AND ';
@@ -212,10 +245,12 @@ export const resolveWhereConditionSuggestions = ({
dbType: string;
}): WhereConditionSuggestion[] => {
const text = String(input || '');
const prefix = normalizeSuggestionPrefix(text).replace(/^["`]/, '').toLowerCase();
const rawPrefix = normalizeSuggestionPrefix(text);
const prefix = rawPrefix.replace(/^["`]/, '').toLowerCase();
const textBeforePrefix = rawPrefix ? text.slice(0, text.length - rawPrefix.length) : text;
const options: WhereConditionSuggestion[] = [];
if (shouldSuggestOperators(text)) {
if (!isLogicalConnectorCursor(text) && shouldSuggestOperators(text)) {
WHERE_OPERATORS.forEach((operator) => {
const insertText = toOperatorInsertText(operator);
options.push({
@@ -229,6 +264,29 @@ export const resolveWhereConditionSuggestions = ({
return options;
}
if (isAwaitingValueAfterOperator(textBeforePrefix)) {
return options;
}
if (hasCompletedPredicateBeforeCursor(textBeforePrefix)) {
if (!prefix && !/\s$/.test(text)) {
return options;
}
WHERE_CONTINUATION_KEYWORDS
.filter((keyword) => !prefix || keyword.toLowerCase().startsWith(prefix))
.forEach((keyword) => {
const insertText = `${keyword} `;
options.push({
label: keyword,
insertText,
value: applyWhereConditionSuggestion(text, insertText),
detail: '关键字',
kind: 'keyword',
});
});
return options;
}
(columnNames || [])
.map((column) => toTrimmedString(column))
.filter(Boolean)

View File

@@ -16,7 +16,54 @@ export type DriverManagerWorkbenchTheme = {
warningText: string;
};
/** v2 = body[data-ui-version="v2"],由 App.tsx 切换。 */
const isV2 = (): boolean => {
if (typeof document === 'undefined' || !document.body) return false;
return document.body.getAttribute('data-ui-version') === 'v2';
};
export const buildDriverManagerWorkbenchTheme = (darkMode: boolean, _opacity: number): DriverManagerWorkbenchTheme => {
// ─── v2 palette ──────────────────────────────────────────────
if (isV2()) {
if (darkMode) {
return {
isDark: true,
pageBg: '#161a21',
sectionBg: '#1b1f27',
sectionBorder: '0.5px solid rgba(255,255,255,0.06)',
cardBg: '#161a21',
cardBorder: '0.5px solid rgba(255,255,255,0.10)',
cardWarningBorder: '0.5px solid rgba(245, 158, 11, 0.36)',
cardReadyBorder: '0.5px solid rgba(34, 197, 94, 0.36)',
statBg: '#1b1f27',
statBorder: '0.5px solid rgba(255,255,255,0.06)',
updateNoteBg: 'rgba(245, 158, 11, 0.10)',
updateNoteBorder: '0.5px solid rgba(245, 158, 11, 0.30)',
mutedText: '#80868f',
titleText: '#f1f3f5',
warningText: '#f59e0b',
};
}
return {
isDark: false,
pageBg: '#ffffff',
sectionBg: '#fafaf8',
sectionBorder: '0.5px solid rgba(15,23,42,0.08)',
cardBg: '#ffffff',
cardBorder: '0.5px solid rgba(15,23,42,0.12)',
cardWarningBorder: '0.5px solid rgba(217, 119, 6, 0.32)',
cardReadyBorder: '0.5px solid rgba(22, 163, 74, 0.28)',
statBg: '#fafaf8',
statBorder: '0.5px solid rgba(15,23,42,0.08)',
updateNoteBg: 'rgba(217, 119, 6, 0.08)',
updateNoteBorder: '0.5px solid rgba(217, 119, 6, 0.24)',
mutedText: '#6b7280',
titleText: '#0c1322',
warningText: '#d97706',
};
}
// ─── legacy palette ──────────────────────────────────────────
if (darkMode) {
const darkSurface = 'rgb(31, 31, 31)';

View File

@@ -1,5 +1,15 @@
import { resolveTextInputSafeBackdropFilter } from './appearance';
/**
* 是否处于 v2 界面版本。
* 读取 body[data-ui-version],由 App.tsx 在切换时设置。
* 在 SSR / 测试环境(无 document时安全 fallback 为 false。
*/
const isV2 = (): boolean => {
if (typeof document === 'undefined' || !document.body) return false;
return document.body.getAttribute('data-ui-version') === 'v2';
};
type OverlayWorkbenchTheme = {
isDark: boolean;
shellBg: string;
@@ -27,6 +37,47 @@ export const buildOverlayWorkbenchTheme = (
options?.disableBackdropFilter ?? false,
);
// ─── v2 palette ──────────────────────────────────────────────
if (isV2()) {
if (darkMode) {
return {
isDark: true,
shellBg: 'linear-gradient(180deg, rgba(22, 26, 33, 0.96) 0%, rgba(12, 14, 18, 0.98) 100%)',
shellBorder: '0.5px solid rgba(255,255,255,0.10)',
shellShadow: '0 12px 40px rgba(0,0,0,0.55)',
shellBackdropFilter,
sectionBg: 'rgba(255,255,255,0.04)',
sectionBorder: '0.5px solid rgba(255,255,255,0.06)',
mutedText: '#80868f',
titleText: '#f1f3f5',
iconBg: 'rgba(34, 197, 94, 0.16)',
iconColor: '#22c55e',
hoverBg: 'rgba(255,255,255,0.05)',
selectedBg: 'rgba(34, 197, 94, 0.14)',
selectedText: '#4ade80',
divider: 'rgba(255,255,255,0.06)',
};
}
return {
isDark: false,
shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(250,250,248,0.98) 100%)',
shellBorder: '0.5px solid rgba(15,23,42,0.12)',
shellShadow: '0 12px 40px rgba(15,23,42,0.14)',
shellBackdropFilter,
sectionBg: 'rgba(255,255,255,0.85)',
sectionBorder: '0.5px solid rgba(15,23,42,0.08)',
mutedText: '#6b7280',
titleText: '#0c1322',
iconBg: '#dcfce7',
iconColor: '#16a34a',
hoverBg: 'rgba(15,23,42,0.045)',
selectedBg: 'rgba(34, 197, 94, 0.10)',
selectedText: '#15803d',
divider: 'rgba(15,23,42,0.08)',
};
}
// ─── legacy palette (existing behavior) ──────────────────────
if (darkMode) {
return {
isDark: true,

View File

@@ -8,6 +8,10 @@ import {
normalizeShortcutCombo,
RESERVED_SHORTCUTS,
comboToMonacoKeyBinding,
getShortcutDisplayLabel,
resolveShortcutBinding,
resolveShortcutDisplay,
sanitizeShortcutOptions,
SHORTCUT_ACTION_META,
} from './shortcuts';
import type { ConflictInfo } from './shortcuts';
@@ -120,8 +124,8 @@ describe('RESERVED_SHORTCUTS', () => {
describe('shortcut defaults', () => {
it('registers select current statement as a query editor shortcut', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.selectCurrentStatement).toEqual({
combo: 'Ctrl+E',
enabled: true,
mac: { combo: 'Meta+E', enabled: true },
windows: { combo: 'Ctrl+E', enabled: true },
});
expect(SHORTCUT_ACTION_META.selectCurrentStatement).toMatchObject({
label: '选择当前语句',
@@ -133,14 +137,87 @@ describe('shortcut defaults', () => {
// 自动 fix 路径9848b8b2刻意不再 toggle 以避免可见动画,由该快捷键给用户主动触发的修复入口。
it('registers reset window zoom shortcut with default Ctrl+Shift+0', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.resetWindowZoom).toEqual({
combo: 'Ctrl+Shift+0',
enabled: true,
mac: { combo: '', enabled: false },
windows: { combo: 'Ctrl+Shift+0', enabled: true },
});
expect(SHORTCUT_ACTION_META.resetWindowZoom).toMatchObject({
label: '重置窗口缩放',
allowInEditable: true,
});
});
it('uses Navicat-inspired defaults separately for macOS and Windows/Linux', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.runQuery).toEqual({
mac: { combo: 'Meta+R', enabled: true },
windows: { combo: 'Ctrl+R', enabled: true },
});
expect(DEFAULT_SHORTCUT_OPTIONS.newQueryTab).toEqual({
mac: { combo: 'Meta+Y', enabled: true },
windows: { combo: 'Ctrl+Q', enabled: true },
});
expect(DEFAULT_SHORTCUT_OPTIONS.toggleLogPanel).toEqual({
mac: { combo: 'Meta+Shift+H', enabled: true },
windows: { combo: 'Ctrl+H', enabled: true },
});
});
it('registers connection and AI panel actions as real shortcuts', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.newConnection).toEqual({
mac: { combo: 'Meta+N', enabled: true },
windows: { combo: 'Ctrl+N', enabled: true },
});
expect(DEFAULT_SHORTCUT_OPTIONS.toggleAIPanel).toEqual({
mac: { combo: 'Meta+J', enabled: true },
windows: { combo: 'Ctrl+J', enabled: true },
});
expect(SHORTCUT_ACTION_META.newConnection.label).toBe('新建数据源');
expect(SHORTCUT_ACTION_META.toggleAIPanel.label).toBe('打开 AI 数据洞察');
});
it('migrates legacy single-platform shortcut bindings into both platform slots', () => {
const options = sanitizeShortcutOptions({
runQuery: { combo: 'Ctrl+Shift+R', enabled: false },
});
expect(options.runQuery).toEqual({
mac: { combo: 'Ctrl+Shift+R', enabled: false },
windows: { combo: 'Ctrl+Shift+R', enabled: false },
});
expect(options.newQueryTab.windows.combo).toBe('Ctrl+Q');
});
it('sanitizes partial platform shortcut bindings without losing defaults', () => {
const options = sanitizeShortcutOptions({
newQueryTab: {
mac: { combo: 'Meta+Y', enabled: false },
},
sendAIChatMessage: {
windows: { combo: 'A', enabled: true },
},
});
expect(options.newQueryTab.mac).toEqual({ combo: 'Meta+Y', enabled: false });
expect(options.newQueryTab.windows).toEqual({ combo: 'Ctrl+Q', enabled: true });
expect(options.sendAIChatMessage.windows).toEqual({ combo: 'Enter', enabled: true });
});
it('resolves and displays platform-specific bindings', () => {
const options = sanitizeShortcutOptions({
newQueryTab: {
mac: { combo: 'Meta+Y', enabled: true },
windows: { combo: 'Ctrl+Q', enabled: true },
},
});
expect(resolveShortcutBinding(options, 'newQueryTab', 'mac')).toEqual({
combo: 'Meta+Y',
enabled: true,
});
expect(getShortcutDisplayLabel('Meta+N', 'mac')).toBe('⌘N');
expect(getShortcutDisplayLabel('Meta+Shift+H', 'mac')).toBe('⌘⇧H');
expect(getShortcutDisplayLabel('Ctrl+Meta+F', 'mac')).toBe('⌃⌘F');
expect(resolveShortcutDisplay(options, 'newQueryTab', 'windows')).toBe('Ctrl+Q');
});
});
// ─── comboToMonacoKeyBinding ─────────────────────────────────────────

View File

@@ -6,17 +6,23 @@ export type ShortcutAction =
| 'sendAIChatMessage'
| 'focusSidebarSearch'
| 'newQueryTab'
| 'newConnection'
| 'toggleAIPanel'
| 'toggleLogPanel'
| 'toggleTheme'
| 'openShortcutManager'
| 'toggleMacFullscreen'
| 'resetWindowZoom';
export interface ShortcutBinding {
export type ShortcutPlatform = 'mac' | 'windows';
export interface ShortcutPlatformBinding {
combo: string;
enabled: boolean;
}
export type ShortcutBinding = Record<ShortcutPlatform, ShortcutPlatformBinding>;
export type ShortcutOptions = Record<ShortcutAction, ShortcutBinding>;
export interface ShortcutActionMeta {
@@ -84,6 +90,8 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'sendAIChatMessage',
'focusSidebarSearch',
'newQueryTab',
'newConnection',
'toggleAIPanel',
'toggleLogPanel',
'toggleTheme',
'openShortcutManager',
@@ -119,6 +127,15 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
label: '新建查询页',
description: '创建一个新的 SQL 查询标签页',
},
newConnection: {
label: '新建数据源',
description: '创建新的数据库、运行时或其他数据源连接',
},
toggleAIPanel: {
label: '打开 AI 数据洞察',
description: '打开右侧 AI 数据洞察面板',
allowInEditable: true,
},
toggleLogPanel: {
label: '切换日志面板',
description: '打开或关闭 SQL 执行日志面板',
@@ -145,16 +162,54 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
};
export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
runQuery: { combo: 'Ctrl+Shift+R', enabled: true },
selectCurrentStatement: { combo: 'Ctrl+E', enabled: true },
sendAIChatMessage: { combo: 'Enter', enabled: true },
focusSidebarSearch: { combo: 'Ctrl+F', enabled: true },
newQueryTab: { combo: 'Ctrl+Shift+N', enabled: true },
toggleLogPanel: { combo: 'Ctrl+Shift+L', enabled: true },
toggleTheme: { combo: 'Ctrl+Shift+D', enabled: true },
openShortcutManager: { combo: 'Ctrl+,', enabled: true },
toggleMacFullscreen: { combo: 'Ctrl+Meta+F', enabled: true },
resetWindowZoom: { combo: 'Ctrl+Shift+0', enabled: true },
runQuery: {
mac: { combo: 'Meta+R', enabled: true },
windows: { combo: 'Ctrl+R', enabled: true },
},
selectCurrentStatement: {
mac: { combo: 'Meta+E', enabled: true },
windows: { combo: 'Ctrl+E', enabled: true },
},
sendAIChatMessage: {
mac: { combo: 'Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },
},
focusSidebarSearch: {
mac: { combo: 'Meta+K', enabled: true },
windows: { combo: 'Ctrl+K', enabled: true },
},
newQueryTab: {
mac: { combo: 'Meta+Y', enabled: true },
windows: { combo: 'Ctrl+Q', enabled: true },
},
newConnection: {
mac: { combo: 'Meta+N', enabled: true },
windows: { combo: 'Ctrl+N', enabled: true },
},
toggleAIPanel: {
mac: { combo: 'Meta+J', enabled: true },
windows: { combo: 'Ctrl+J', enabled: true },
},
toggleLogPanel: {
mac: { combo: 'Meta+Shift+H', enabled: true },
windows: { combo: 'Ctrl+H', enabled: true },
},
toggleTheme: {
mac: { combo: 'Meta+Shift+D', enabled: true },
windows: { combo: 'Ctrl+Shift+D', enabled: true },
},
openShortcutManager: {
mac: { combo: 'Meta+,', enabled: true },
windows: { combo: 'Ctrl+,', enabled: true },
},
toggleMacFullscreen: {
mac: { combo: 'Ctrl+Meta+F', enabled: true },
windows: { combo: '', enabled: false },
},
resetWindowZoom: {
mac: { combo: '', enabled: false },
windows: { combo: 'Ctrl+Shift+0', enabled: true },
},
};
const normalizeKeyToken = (value: string): string => {
@@ -239,6 +294,10 @@ export const isShortcutMatch = (event: KeyboardEvent | ReactKeyboardEvent, combo
return actual === expected;
};
export const getShortcutPlatform = (isMacRuntime?: boolean): ShortcutPlatform => (
isMacRuntime ? 'mac' : 'windows'
);
export const hasModifierKey = (combo: string): boolean => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized) return false;
@@ -276,16 +335,51 @@ export const canRecordShortcutForAction = (action: ShortcutAction, combo: string
return hasModifierKey(normalized);
};
const cloneShortcutPlatformBinding = (
action: ShortcutAction,
platform: ShortcutPlatform,
value?: Partial<ShortcutPlatformBinding> | null,
): ShortcutPlatformBinding => {
const fallback = DEFAULT_SHORTCUT_OPTIONS[action]?.[platform] ?? { combo: '', enabled: false };
const normalized = normalizeShortcutCombo(value?.combo || fallback.combo);
return {
combo: normalized && canRecordShortcutForAction(action, normalized) ? normalized : fallback.combo,
enabled: value?.enabled === false ? false : fallback.enabled !== false,
};
};
export const cloneShortcutOptions = (value: ShortcutOptions): ShortcutOptions => {
return SHORTCUT_ACTION_ORDER.reduce((acc, action) => {
acc[action] = {
combo: normalizeShortcutCombo(value[action]?.combo || DEFAULT_SHORTCUT_OPTIONS[action].combo),
enabled: value[action]?.enabled !== false,
mac: cloneShortcutPlatformBinding(action, 'mac', value[action]?.mac),
windows: cloneShortcutPlatformBinding(action, 'windows', value[action]?.windows),
};
return acc;
}, {} as ShortcutOptions);
};
const isLegacyShortcutBinding = (value: Record<string, unknown>): boolean => (
Object.prototype.hasOwnProperty.call(value, 'combo')
|| Object.prototype.hasOwnProperty.call(value, 'enabled')
);
const sanitizeShortcutPlatformBinding = (
action: ShortcutAction,
platform: ShortcutPlatform,
value: unknown,
fallback: ShortcutPlatformBinding,
): ShortcutPlatformBinding => {
if (!value || typeof value !== 'object') {
return { ...fallback };
}
const binding = value as Record<string, unknown>;
const combo = normalizeShortcutCombo(String(binding.combo || fallback.combo));
return {
combo: combo && canRecordShortcutForAction(action, combo) ? combo : fallback.combo,
enabled: binding.enabled === false ? false : true,
};
};
export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const defaults = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS);
@@ -296,16 +390,32 @@ export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => {
return;
}
const binding = actionRaw as Record<string, unknown>;
const combo = normalizeShortcutCombo(String(binding.combo || defaults[action].combo));
if (isLegacyShortcutBinding(binding)) {
defaults[action] = {
mac: sanitizeShortcutPlatformBinding(action, 'mac', binding, defaults[action].mac),
windows: sanitizeShortcutPlatformBinding(action, 'windows', binding, defaults[action].windows),
};
return;
}
defaults[action] = {
combo: combo && canRecordShortcutForAction(action, combo) ? combo : defaults[action].combo,
enabled: binding.enabled === false ? false : true,
mac: sanitizeShortcutPlatformBinding(action, 'mac', binding.mac, defaults[action].mac),
windows: sanitizeShortcutPlatformBinding(action, 'windows', binding.windows, defaults[action].windows),
};
});
return defaults;
};
export const resolveShortcutBinding = (
options: Partial<ShortcutOptions> | null | undefined,
action: ShortcutAction,
platform: ShortcutPlatform,
): ShortcutPlatformBinding => {
const defaults = DEFAULT_SHORTCUT_OPTIONS[action];
const binding = options?.[action];
return cloneShortcutPlatformBinding(action, platform, binding?.[platform] ?? defaults[platform]);
};
export const isEditableElement = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
@@ -328,6 +438,44 @@ export const getShortcutDisplay = (combo: string): string => {
return normalized || '-';
};
const DISPLAY_SYMBOLS: Record<string, string> = {
Ctrl: '⌃',
Meta: '⌘',
Alt: '⌥',
Shift: '⇧',
Enter: '↵',
Esc: 'Esc',
Space: 'Space',
Up: '↑',
Down: '↓',
Left: '←',
Right: '→',
};
export const getShortcutDisplayLabel = (
combo: string,
platform: ShortcutPlatform,
): string => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized) return '-';
if (platform !== 'mac') return normalized;
return normalized
.split('+')
.filter(Boolean)
.map((part) => DISPLAY_SYMBOLS[part] || part)
.join('');
};
export const resolveShortcutDisplay = (
options: Partial<ShortcutOptions> | null | undefined,
action: ShortcutAction,
platform: ShortcutPlatform,
): string => {
const binding = resolveShortcutBinding(options, action, platform);
if (!binding.enabled) return '-';
return getShortcutDisplayLabel(binding.combo, platform);
};
export type ConflictContext = 'global' | 'monaco' | 'datagrid';
export interface ReservedShortcut {