mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-05 05:18:05 +08:00
✨ feat(ui): 完成新版 UI 全量改造
- 整体布局:按新版 UI 重构左侧导航、对象树、连接分组和右键菜单体系 - 数据视图:优化 DDL 侧栏、横向滚动、筛选输入、编辑入口和虚拟表格体验 - AI 面板:重构新版入口、输入区、模型选择、快捷键和悬浮布局 - 标签与快捷键:补齐 Tab 悬浮信息、复制交互和 Mac/Windows 快捷键配置 - 工程质量:新增 v2 主题样式、菜单组件、外观工具和回归测试覆盖
This commit is contained in:
@@ -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('快捷键发送已关闭');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user