mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-10 00:19:40 +08:00
✨ feat(ai): 支持录制聊天发送快捷键
- 工具中心新增 AI 聊天发送快捷键,默认 Enter 并支持 Ctrl/Cmd/Alt+Enter - AI 输入框按录制绑定发送,保留 Shift+Enter 换行和输入法 composing 保护 - 修复 shortcutOptions 启动刷新覆盖录制值的问题,并校验脏持久化快捷键 - 补充快捷键、输入框提示和持久化回归测试 - 撤回 macOS Caps Lock 浮层无效前端规避,恢复输入控件 no-auto-cap 属性 - 新增需求进度追踪文档记录验证结果
This commit is contained in:
@@ -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+Enter,Shift+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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 发送');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
89
frontend/src/utils/aiChatSendShortcut.test.ts
Normal file
89
frontend/src/utils/aiChatSendShortcut.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
frontend/src/utils/aiChatSendShortcut.ts
Normal file
50
frontend/src/utils/aiChatSendShortcut.ts
Normal 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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user