feat(ai): 支持录制聊天发送快捷键

- 工具中心新增 AI 聊天发送快捷键,默认 Enter 并支持 Ctrl/Cmd/Alt+Enter
- AI 输入框按录制绑定发送,保留 Shift+Enter 换行和输入法 composing 保护
- 修复 shortcutOptions 启动刷新覆盖录制值的问题,并校验脏持久化快捷键
- 补充快捷键、输入框提示和持久化回归测试
- 撤回 macOS Caps Lock 浮层无效前端规避,恢复输入控件 no-auto-cap 属性
- 新增需求进度追踪文档记录验证结果
This commit is contained in:
Syngnat
2026-04-28 18:12:42 +08:00
parent 56eaca9081
commit 5f7578c5ea
13 changed files with 525 additions and 47 deletions

View File

@@ -18,7 +18,7 @@ import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModa
import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModal';
import { DEFAULT_APPEARANCE, useStore } from './store';
import { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from './utils/appearance';
import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics';
@@ -62,9 +62,9 @@ import {
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
ShortcutAction,
canRecordShortcutForAction,
eventToShortcut,
getShortcutDisplay,
hasModifierKey,
isEditableElement,
isShortcutMatch,
normalizeShortcutCombo,
@@ -836,7 +836,6 @@ function App() {
fontSize: isSidebarCompact ? 13 : 14,
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, isSidebarCompact, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
const disableLocalBackdropFilter = isMacLikePlatform();
const textInputSafeBackdropFilter = resolveTextInputSafeBackdropFilter(blurFilter, disableLocalBackdropFilter);
const overlayTheme = useMemo(
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
[darkMode, disableLocalBackdropFilter],
@@ -2388,11 +2387,15 @@ function App() {
useEffect(() => {
const handleGlobalShortcut = (event: KeyboardEvent) => {
const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => {
const meta = SHORTCUT_ACTION_META[action];
if (meta.scope && meta.scope !== 'global') {
return false;
}
const binding = shortcutOptions[action];
if (!binding?.enabled) {
return false;
}
if (isEditableElement(event.target) && !SHORTCUT_ACTION_META[action].allowInEditable) {
if (isEditableElement(event.target) && !meta.allowInEditable) {
return false;
}
return isShortcutMatch(event, binding.combo);
@@ -2456,12 +2459,15 @@ function App() {
if (!combo) {
return;
}
if (!hasModifierKey(combo)) {
void message.warning('快捷键至少包含 Ctrl / Alt / Shift / Meta 之一');
return;
}
const normalizedCombo = normalizeShortcutCombo(combo);
if (!canRecordShortcutForAction(capturingShortcutAction, normalizedCombo)) {
const meta = SHORTCUT_ACTION_META[capturingShortcutAction];
void message.warning(meta.scope === 'aiComposer'
? 'AI 聊天发送快捷键仅支持 Enter / Ctrl+Enter / Cmd+Enter / Alt+EnterShift+Enter 保留换行'
: '快捷键至少包含 Ctrl / Alt / Shift / Meta 之一');
return;
}
const conflictAction = SHORTCUT_ACTION_ORDER.find((action) => {
if (action === capturingShortcutAction) {
return false;
@@ -2566,8 +2572,8 @@ function App() {
background: 'transparent',
borderRadius: showLinuxResizeHandles ? 0 : 'var(--gonavi-border-radius)',
clipPath: showLinuxResizeHandles ? 'none' : 'inset(0 round var(--gonavi-border-radius))',
backdropFilter: textInputSafeBackdropFilter,
WebkitBackdropFilter: textInputSafeBackdropFilter,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
}}>
{/* Custom Title Bar */}
<div
@@ -3483,7 +3489,7 @@ function App() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
<div style={utilityPanelStyle}>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
Esc Ctrl/Alt/Shift/Meta
Esc AI Enter Shift+Enter
</div>
</div>
{SHORTCUT_ACTION_ORDER.map((action) => {

View File

@@ -27,6 +27,7 @@ import {
} from '../utils/aiComposerNotice';
import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit';
import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool';
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
interface AIChatPanelProps {
width?: number;
@@ -256,6 +257,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const aiPanelVisible = useStore(state => state.aiPanelVisible);
const aiChatSendShortcutBinding = useStore(state => state.shortcutOptions.sendAIChatMessage);
const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => {
const state = useStore.getState();
@@ -1470,11 +1472,8 @@ SELECT * FROM users WHERE status = 1;
]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, e, handleSend);
}, [aiChatSendShortcutBinding, handleSend]);
const handleStop = useCallback(async () => {
try {
@@ -1705,6 +1704,7 @@ SELECT * FROM users WHERE status = 1;
activeProvider={activeProvider}
dynamicModels={dynamicModels}
loadingModels={loadingModels}
sendShortcutBinding={aiChatSendShortcutBinding}
composerNotice={composerNotice}
onModelChange={handleModelChange}
onFetchModels={fetchDynamicModels}

View File

@@ -20,7 +20,6 @@ import {
} from '../utils/aiSettingsPresetLayout';
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface AISettingsModalProps {

View File

@@ -36,6 +36,7 @@ describe('AIChatInput notice layout', () => {
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
composerNotice={{
tone: 'error',
title: '模型列表加载失败',
@@ -58,4 +59,35 @@ describe('AIChatInput notice layout', () => {
expect(inputIndex).toBeGreaterThanOrEqual(0);
expect(noticeIndex).toBeLessThan(inputIndex);
});
it('renders the selected send shortcut in the composer placeholder', () => {
const markup = renderToStaticMarkup(
<AIChatInput
input=""
setInput={() => {}}
draftImages={[]}
setDraftImages={() => {}}
sending={false}
onSend={() => {}}
onStop={() => {}}
handleKeyDown={() => {}}
activeConnName=""
activeContext={null}
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
composerNotice={null}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
/>
);
expect(markup).toContain('Meta+Enter 发送');
});
});

View File

@@ -7,6 +7,8 @@ import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
import type { ShortcutBinding } from '../../utils/shortcuts';
interface AIChatInputProps {
input: string;
@@ -22,6 +24,7 @@ interface AIChatInputProps {
activeProvider: any;
dynamicModels: string[];
loadingModels: boolean;
sendShortcutBinding: ShortcutBinding;
composerNotice?: AIComposerNotice | null;
onModelChange: (val: string) => void;
onFetchModels: () => void;
@@ -37,7 +40,7 @@ interface AIChatInputProps {
export const AIChatInput: React.FC<AIChatInputProps> = ({
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
composerNotice,
sendShortcutBinding, composerNotice,
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
contextUsageChars, maxContextChars
}) => {
@@ -379,7 +382,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
}
}}
onKeyDown={handleKeyDown as any}
placeholder="输入消息... (Enter 发送Shift+Enter 换行,/ 快捷命令)"
placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding)}Shift+Enter 换行,/ 快捷命令)`}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}

View File

@@ -255,4 +255,132 @@ describe('store appearance persistence', () => {
},
]);
});
it('defaults AI chat send shortcut to Enter in shared shortcut options', async () => {
const { useStore } = await importStore();
expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Enter',
enabled: true,
});
});
it('persists recorded AI chat send shortcut and restores it after reload', async () => {
const { useStore } = await importStore();
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Meta+Enter',
enabled: true,
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
});
it('falls back to Enter when persisted AI chat send shortcut is invalid', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
shortcutOptions: {
sendAIChatMessage: {
combo: 'A',
enabled: true,
},
},
},
version: 8,
}));
const { useStore } = await importStore();
expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Enter',
enabled: true,
});
});
it('does not overwrite recorded AI chat send shortcut during startup config refresh', async () => {
const { useStore } = await importStore();
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Ctrl+Enter',
enabled: true,
});
useStore.getState().replaceConnections([]);
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Ctrl+Enter',
enabled: true,
});
});
it('keeps persisted AI chat send shortcut when startup refresh runs before shortcut hydration catches up', async () => {
const { useStore } = await importStore();
const shortcutOptions = useStore.getState().shortcutOptions;
storage.setItem('lite-db-storage', JSON.stringify({
state: {
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
combo: 'Meta+Enter',
enabled: true,
},
},
},
version: 8,
}));
useStore.setState({
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
combo: 'Enter',
enabled: true,
},
},
});
useStore.getState().replaceConnections([]);
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
});
it('does not let a stale default shortcut state overwrite an explicitly recorded AI chat shortcut', async () => {
const { useStore } = await importStore();
const shortcutOptions = useStore.getState().shortcutOptions;
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Meta+Enter',
enabled: true,
});
useStore.setState({
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
combo: 'Enter',
enabled: true,
},
},
});
useStore.getState().replaceGlobalProxy({});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
});
});

View File

@@ -61,6 +61,7 @@ const MAX_TIMEOUT_SECONDS = 3600;
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
const PERSIST_VERSION = 8;
const PERSIST_STORAGE_KEY = "lite-db-storage";
const DEFAULT_CONNECTION_TYPE = "mysql";
const DEFAULT_JVM_PORT = 9010;
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
@@ -1156,6 +1157,46 @@ const unwrapPersistedAppState = (
return raw;
};
let shortcutOptionsExplicitlySet = false;
const readPersistedShortcutOptions = (): ShortcutOptions | null => {
if (typeof localStorage === "undefined") {
return null;
}
try {
const payload = localStorage.getItem(PERSIST_STORAGE_KEY);
if (!payload) {
return null;
}
const state = unwrapPersistedAppState(JSON.parse(payload));
if (state.shortcutOptions === undefined) {
return null;
}
return sanitizeShortcutOptions(state.shortcutOptions);
} catch {
return null;
}
};
const resolveShortcutOptionsForPersistence = (
shortcutOptions: ShortcutOptions,
): ShortcutOptions => {
const safeOptions = sanitizeShortcutOptions(shortcutOptions);
if (shortcutOptionsExplicitlySet) {
return safeOptions;
}
return readPersistedShortcutOptions() ?? safeOptions;
};
const runWithExplicitShortcutPersistence = (callback: () => void): void => {
shortcutOptionsExplicitlySet = true;
try {
callback();
} finally {
shortcutOptionsExplicitlySet = false;
}
};
// --- AI 会话文件持久化辅助函数 ---
/** 每个 session 独立防抖定时器2秒 */
@@ -1294,7 +1335,10 @@ export const useStore = create<AppState>()(
})),
})),
replaceConnections: (connections) =>
set({ connections: sanitizeConnections(connections) }),
set((state) => ({
connections: sanitizeConnections(connections),
shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions,
})),
addConnectionTag: (tag) =>
set((state) => ({ connectionTags: [...state.connectionTags, tag] })),
@@ -1606,31 +1650,38 @@ export const useStore = create<AppState>()(
globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }),
})),
replaceGlobalProxy: (proxy) =>
set({
set((state) => ({
globalProxy: sanitizeGlobalProxy({
...DEFAULT_GLOBAL_PROXY,
...proxy,
}),
}),
shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions,
})),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
setQueryOptions: (options) =>
set((state) => ({
queryOptions: { ...state.queryOptions, ...options },
})),
updateShortcut: (action, binding) =>
set((state) => ({
shortcutOptions: {
...state.shortcutOptions,
[action]: {
...state.shortcutOptions[action],
...binding,
updateShortcut: (action, binding) => {
runWithExplicitShortcutPersistence(() => {
set((state) => ({
shortcutOptions: {
...state.shortcutOptions,
[action]: {
...state.shortcutOptions[action],
...binding,
},
},
},
})),
resetShortcutOptions: () =>
set({
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
}),
}));
});
},
resetShortcutOptions: () => {
runWithExplicitShortcutPersistence(() => {
set({
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
});
});
},
addSqlLog: (log) =>
set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
@@ -1931,7 +1982,7 @@ export const useStore = create<AppState>()(
})),
}),
{
name: "lite-db-storage", // name of the item in the storage (must be unique)
name: PERSIST_STORAGE_KEY, // name of the item in the storage (must be unique)
version: PERSIST_VERSION,
migrate: (persistedState: unknown, version: number) => {
const state = unwrapPersistedAppState(
@@ -2054,7 +2105,7 @@ export const useStore = create<AppState>()(
: toPersistedGlobalProxy(state.globalProxy),
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
shortcutOptions: state.shortcutOptions,
shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,

View File

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';
import {
canRecordShortcutForAction,
DEFAULT_SHORTCUT_OPTIONS,
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
type ShortcutBinding,
} from './shortcuts';
import {
consumeAIChatSendShortcutOnKeyDown,
getAIChatSendShortcutLabel,
shouldSendAIChatOnKeyDown,
} from './aiChatSendShortcut';
const binding = (combo: string, enabled = true): ShortcutBinding => ({ 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(SHORTCUT_ACTION_META.sendAIChatMessage).toMatchObject({
label: 'AI 聊天发送',
allowInEditable: true,
allowWithoutModifier: true,
scope: 'aiComposer',
requiredKey: 'Enter',
disallowShift: true,
});
});
it('allows recording only single-modifier Enter-based AI send shortcuts', () => {
expect(canRecordShortcutForAction('sendAIChatMessage', 'Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Meta+Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Alt+Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'A')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Shift+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Shift+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Alt+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Meta+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Meta+Alt+Enter')).toBe(false);
});
it('keeps modifier requirements for global shortcuts', () => {
expect(canRecordShortcutForAction('runQuery', 'Enter')).toBe(false);
expect(canRecordShortcutForAction('runQuery', 'Ctrl+Enter')).toBe(true);
});
it('sends on the configured Enter shortcut but never during composition or Shift+Enter', () => {
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter' })).toBe(true);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', shiftKey: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', isComposing: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', nativeEvent: { isComposing: true } })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'a' })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter', false), { key: 'Enter' })).toBe(false);
});
it('matches recorded Cmd or Ctrl Enter shortcuts', () => {
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter' })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter', metaKey: true })).toBe(true);
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter', ctrlKey: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', ctrlKey: true })).toBe(true);
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', metaKey: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', ctrlKey: true, isComposing: true })).toBe(false);
});
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('Enter', false))).toBe('快捷键发送已关闭');
});
it('stops propagation after consuming the configured AI send shortcut', () => {
const event = {
key: 'Enter',
metaKey: true,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
const onSend = vi.fn();
expect(consumeAIChatSendShortcutOnKeyDown(binding('Meta+Enter'), event, onSend)).toBe(true);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(event.stopPropagation).toHaveBeenCalledTimes(1);
expect(onSend).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,50 @@
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplay, isShortcutMatch, type ShortcutBinding } from './shortcuts';
export interface AIChatSendShortcutKeyEventLike {
key?: string;
shiftKey?: boolean;
metaKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
isComposing?: boolean;
nativeEvent?: {
isComposing?: boolean;
};
preventDefault?: () => void;
stopPropagation?: () => void;
}
export const getAIChatSendShortcutLabel = (binding: ShortcutBinding | undefined): string => {
if (binding?.enabled === false) {
return '快捷键发送已关闭';
}
const combo = binding?.combo || DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage.combo;
return `${getShortcutDisplay(combo)} 发送`;
};
export const shouldSendAIChatOnKeyDown = (
binding: ShortcutBinding | undefined,
event: AIChatSendShortcutKeyEventLike,
): boolean => {
if (!binding?.enabled) {
return false;
}
if (event.shiftKey || event.isComposing || event.nativeEvent?.isComposing) {
return false;
}
return isShortcutMatch(event as KeyboardEvent, binding.combo);
};
export const consumeAIChatSendShortcutOnKeyDown = (
binding: ShortcutBinding | undefined,
event: AIChatSendShortcutKeyEventLike,
onSend: () => void,
): boolean => {
if (!shouldSendAIChatOnKeyDown(binding, event)) {
return false;
}
event.preventDefault?.();
event.stopPropagation?.();
onSend();
return true;
};

View File

@@ -3,14 +3,15 @@ import { describe, expect, it } from 'vitest';
import { applyNoAutoCapAttributes, applyNoAutoCapAttributesWithin, noAutoCapInputProps } from './inputAutoCap';
describe('inputAutoCap', () => {
it('exports input props that disable correction without forcing native capitalization state', () => {
it('exports input props that disable auto capitalization and correction', () => {
expect(noAutoCapInputProps).toEqual({
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
});
});
it('applies correction attributes to inputs and textareas without autocapitalize', () => {
it('applies no-auto-cap attributes to inputs and textareas', () => {
const inputAttributes: Record<string, string> = {};
const textareaAttributes: Record<string, string> = {};
const input = {
@@ -29,10 +30,10 @@ describe('inputAutoCap', () => {
applyNoAutoCapAttributes(input);
applyNoAutoCapAttributes(textarea);
expect(inputAttributes.autocapitalize).toBeUndefined();
expect(inputAttributes.autocapitalize).toBe('none');
expect(inputAttributes.autocorrect).toBe('off');
expect(inputAttributes.spellcheck).toBe('false');
expect(textareaAttributes.autocapitalize).toBeUndefined();
expect(textareaAttributes.autocapitalize).toBe('none');
expect(textareaAttributes.autocorrect).toBe('off');
expect(textareaAttributes.spellcheck).toBe('false');
});
@@ -61,9 +62,9 @@ describe('inputAutoCap', () => {
applyNoAutoCapAttributesWithin(root);
expect(inputAttributes.autocapitalize).toBeUndefined();
expect(inputAttributes.autocapitalize).toBe('none');
expect(inputAttributes.autocorrect).toBe('off');
expect(textareaAttributes.autocapitalize).toBeUndefined();
expect(textareaAttributes.autocapitalize).toBe('none');
expect(textareaAttributes.autocorrect).toBe('off');
});
});

View File

@@ -1,4 +1,5 @@
export const noAutoCapInputProps = {
autoCapitalize: 'none' as const,
autoCorrect: 'off' as const,
spellCheck: false,
};
@@ -9,9 +10,7 @@ export const applyNoAutoCapAttributes = (element: Element) => {
return;
}
if (typeof element.removeAttribute === 'function') {
element.removeAttribute('autocapitalize');
}
element.setAttribute('autocapitalize', 'none');
element.setAttribute('autocorrect', 'off');
element.setAttribute('spellcheck', 'false');
};

View File

@@ -2,6 +2,7 @@ import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
export type ShortcutAction =
| 'runQuery'
| 'sendAIChatMessage'
| 'focusSidebarSearch'
| 'newQueryTab'
| 'toggleLogPanel'
@@ -20,6 +21,10 @@ export interface ShortcutActionMeta {
label: string;
description: string;
allowInEditable?: boolean;
allowWithoutModifier?: boolean;
scope?: 'global' | 'aiComposer';
requiredKey?: string;
disallowShift?: boolean;
platformOnly?: 'mac';
}
@@ -73,6 +78,7 @@ const KEY_ALIASES: Record<string, string> = {
export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'runQuery',
'sendAIChatMessage',
'focusSidebarSearch',
'newQueryTab',
'toggleLogPanel',
@@ -86,6 +92,15 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
label: '执行 SQL',
description: '在当前查询页执行 SQL',
},
sendAIChatMessage: {
label: 'AI 聊天发送',
description: '在 AI 输入框中发送当前消息Shift+Enter 始终换行',
allowInEditable: true,
allowWithoutModifier: true,
scope: 'aiComposer',
requiredKey: 'Enter',
disallowShift: true,
},
focusSidebarSearch: {
label: '聚焦侧边栏搜索',
description: '定位到左侧连接树搜索框',
@@ -117,6 +132,7 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
runQuery: { combo: 'Ctrl+Shift+R', 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 },
@@ -213,6 +229,37 @@ export const hasModifierKey = (combo: string): boolean => {
return normalized.split('+').some(part => MODIFIER_SET.has(part as typeof MODIFIER_ORDER[number]));
};
const getShortcutKeyToken = (combo: string): string => {
const parts = normalizeShortcutCombo(combo).split('+').filter(Boolean);
const key = parts[parts.length - 1] || '';
return MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number]) ? '' : key;
};
const getShortcutModifierTokens = (combo: string): string[] => (
normalizeShortcutCombo(combo)
.split('+')
.filter(part => MODIFIER_SET.has(part as typeof MODIFIER_ORDER[number]))
);
export const canRecordShortcutForAction = (action: ShortcutAction, combo: string): boolean => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized || !getShortcutKeyToken(normalized)) {
return false;
}
const meta = SHORTCUT_ACTION_META[action];
if (meta.requiredKey && getShortcutKeyToken(normalized) !== normalizeShortcutCombo(meta.requiredKey)) {
return false;
}
if (meta.disallowShift && normalized.split('+').includes('Shift')) {
return false;
}
if (meta.allowWithoutModifier) {
return getShortcutModifierTokens(normalized).length <= 1;
}
return hasModifierKey(normalized);
};
export const cloneShortcutOptions = (value: ShortcutOptions): ShortcutOptions => {
return SHORTCUT_ACTION_ORDER.reduce((acc, action) => {
acc[action] = {
@@ -235,7 +282,7 @@ export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => {
const binding = actionRaw as Record<string, unknown>;
const combo = normalizeShortcutCombo(String(binding.combo || defaults[action].combo));
defaults[action] = {
combo: combo || defaults[action].combo,
combo: combo && canRecordShortcutForAction(action, combo) ? combo : defaults[action].combo,
enabled: binding.enabled === false ? false : true,
};
});