Merge pull request #451 from TonyJiangWJ/feature/sql-snippets

# Conflicts:
#	frontend/package.json.md5
This commit is contained in:
Syngnat
2026-05-10 12:46:45 +08:00
10 changed files with 937 additions and 4 deletions

View File

@@ -1 +1 @@
d0464f9da25e9356e61652e638c99ffe
d0464f9da25e9356e61652e638c99ffe

View File

@@ -6,6 +6,7 @@ import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGe
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
import SnippetSettingsModal from './components/SnippetSettingsModal';
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
import DataSyncModal from './components/DataSyncModal';
import DriverManagerModal from './components/DriverManagerModal';
@@ -1886,6 +1887,7 @@ function App() {
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
const [isSnippetModalOpen, setIsSnippetModalOpen] = useState(false);
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
const [isDataRootModalOpen, setIsDataRootModalOpen] = useState(false);
@@ -2425,6 +2427,16 @@ function App() {
};
}, []);
useEffect(() => {
const handleOpenSnippetSettingsEvent = () => {
setIsSnippetModalOpen(true);
};
window.addEventListener('gonavi:open-snippet-settings', handleOpenSnippetSettingsEvent as EventListener);
return () => {
window.removeEventListener('gonavi:open-snippet-settings', handleOpenSnippetSettingsEvent as EventListener);
};
}, []);
useEffect(() => {
if (!isMacRuntime || !useNativeMacWindowControls) {
return;
@@ -3606,6 +3618,12 @@ function App() {
})}
</div>
</Modal>
<SnippetSettingsModal
open={isSnippetModalOpen}
onClose={() => setIsSnippetModalOpen(false)}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
<Modal
title={renderUtilityModalTitle(<GlobalOutlined />, '全局代理设置', '统一配置更新检查、驱动管理与未单独指定代理的连接网络出口。')}
open={isProxyModalOpen}

View File

@@ -1372,6 +1372,41 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
},
});
// SQL snippet completion provider
monaco.languages.registerCompletionItemProvider('sql', {
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
const prefix = word.word.toLowerCase();
if (!prefix) return { suggestions: [] };
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const allSnippets = useStore.getState().sqlSnippets;
const matched = allSnippets.filter(s =>
s.prefix.toLowerCase().startsWith(prefix) ||
s.name.toLowerCase().includes(prefix)
);
return {
suggestions: matched.map(s => ({
label: s.prefix,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: s.body,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: s.name,
documentation: s.description || s.body,
range,
sortText: '04' + s.prefix,
})),
};
},
});
} // end sqlCompletionRegistered guard
// 每个编辑器实例都注册内容变化监听(检测斜杠命令标记)
@@ -1462,6 +1497,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
onClick: () => setSqlFormatOptions({ keywordCase: 'lower' })
},
{ type: 'divider' },
{
key: 'snippet-settings',
label: '代码片段管理...',
onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-snippet-settings')),
},
{
key: 'shortcut-settings',
label: '快捷键管理...',

View File

@@ -0,0 +1,422 @@
import { useState, useMemo, useCallback } from 'react';
import { Modal, Button, Input, List, Tag, Popconfirm, message, Collapse, Typography } from 'antd';
import {
PlusOutlined,
DeleteOutlined,
UndoOutlined,
SaveOutlined,
CodeOutlined,
} from '@ant-design/icons';
import { v4 as uuidv4 } from 'uuid';
import type { SqlSnippet } from '../types';
import { useStore } from '../store';
import { BUILTIN_SNIPPET_MAP } from '../utils/sqlSnippetDefaults';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface SnippetSettingsModalProps {
open: boolean;
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
}
type DraftSnippet = Omit<SqlSnippet, 'createdAt'> & { createdAt?: number };
const emptyDraft = (): DraftSnippet => ({
id: uuidv4(),
prefix: '',
name: '',
description: '',
body: '',
isBuiltin: false,
});
export default function SnippetSettingsModal({
open,
onClose,
darkMode,
overlayTheme,
}: SnippetSettingsModalProps) {
const sqlSnippets = useStore((s) => s.sqlSnippets);
const saveSqlSnippet = useStore((s) => s.saveSqlSnippet);
const deleteSqlSnippet = useStore((s) => s.deleteSqlSnippet);
const resetBuiltinSqlSnippet = useStore((s) => s.resetBuiltinSqlSnippet);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftSnippet>(emptyDraft());
const [isCreating, setIsCreating] = useState(false);
const shellStyle = useMemo(
() => ({
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}),
[overlayTheme],
);
const panelStyle = useMemo(
() => ({
padding: 16,
borderRadius: 14,
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}),
[overlayTheme],
);
const textColor = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(16,24,40,0.9)';
const mutedColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)';
const selectedBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
const sortedSnippets = useMemo(
() => [...sqlSnippets].sort((a, b) => a.prefix.localeCompare(b.prefix)),
[sqlSnippets],
);
const selectedSnippet = useMemo(
() => sqlSnippets.find((s) => s.id === selectedId) ?? null,
[sqlSnippets, selectedId],
);
const handleSelect = useCallback(
(snippet: SqlSnippet) => {
setIsCreating(false);
setSelectedId(snippet.id);
setDraft({ ...snippet });
},
[],
);
const handleNew = useCallback(() => {
setIsCreating(true);
setSelectedId(null);
setDraft(emptyDraft());
}, []);
const handleSave = useCallback(() => {
const prefix = draft.prefix.toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20);
if (!prefix) {
void message.warning('前缀不能为空');
return;
}
if (!draft.name.trim()) {
void message.warning('名称不能为空');
return;
}
if (!draft.body.trim()) {
void message.warning('片段内容不能为空');
return;
}
const duplicate = sqlSnippets.find(
(s) => s.prefix.toLowerCase() === prefix && s.id !== draft.id,
);
if (duplicate) {
void message.warning(`前缀 "${prefix}" 已被其他片段使用`);
return;
}
const toSave: SqlSnippet = {
id: draft.id,
prefix,
name: draft.name.trim(),
description: draft.description?.trim() || undefined,
body: draft.body,
isBuiltin: draft.isBuiltin,
createdAt: draft.createdAt ?? Date.now(),
};
saveSqlSnippet(toSave);
setSelectedId(toSave.id);
setIsCreating(false);
void message.success('片段已保存');
}, [draft, sqlSnippets, saveSqlSnippet]);
const handleDelete = useCallback(
(id: string) => {
deleteSqlSnippet(id);
if (selectedId === id) {
setSelectedId(null);
setDraft(emptyDraft());
}
void message.success('片段已删除');
},
[deleteSqlSnippet, selectedId],
);
const handleReset = useCallback(
(id: string) => {
resetBuiltinSqlSnippet(id);
const original = BUILTIN_SNIPPET_MAP[id];
if (original && selectedId === id) {
setDraft({ ...original });
}
void message.success('已重置为默认');
},
[resetBuiltinSqlSnippet, selectedId],
);
const syntaxHelpItems = [
{
key: 'syntax',
label: '片段语法说明',
children: (
<div style={{ fontSize: 12, lineHeight: 1.8, color: mutedColor, fontFamily: 'monospace' }}>
<div>{'${1:占位符} 第一个 Tab 位,占位符为提示文字'}</div>
<div>{'${2:默认值} 第二个 Tab 位,默认值可直接确认'}</div>
<div>{'$0 最终光标位置'}</div>
<div>{'${1:表名} 同一数字在多处出现时会同步编辑'}</div>
<div style={{ marginTop: 6, fontWeight: 600, color: textColor }}>{'内置变量(展开时自动替换为实际值):'}</div>
<div>{'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} 当前日期'}</div>
<div>{'${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND} 当前时间'}</div>
<div>{'${CURRENT_SECONDS_UNIX} Unix 时间戳'}</div>
<div>{'${UUID} 随机 UUID'}</div>
<div>{'${RANDOM} 6 位随机数'}</div>
<div style={{ marginTop: 8, fontFamily: 'inherit', color: textColor }}>
{'示例SELECT ${1:列名} FROM ${2:表名} WHERE date >= \'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}\';$0'}
</div>
</div>
),
},
];
const showEditor = isCreating || selectedSnippet;
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
flexShrink: 0,
}}
>
<CodeOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 600, color: textColor }}></div>
<div style={{ fontSize: 12, color: mutedColor, lineHeight: 1.5 }}>
SQL Tab
</div>
</div>
</div>
}
open={open}
onCancel={onClose}
width={820}
styles={{
content: shellStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 40 },
}}
footer={[
<Button key="close" type="primary" onClick={onClose}>
</Button>,
]}
>
<div style={{ display: 'flex', gap: 16, minHeight: 420 }}>
{/* Left: snippet list */}
<div
style={{
width: 220,
flexShrink: 0,
borderRadius: 14,
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ padding: '8px 12px 4px', fontSize: 12, color: mutedColor, fontWeight: 600 }}>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
<List
size="small"
dataSource={sortedSnippets}
renderItem={(snippet) => (
<List.Item
onClick={() => handleSelect(snippet)}
style={{
cursor: 'pointer',
padding: '6px 12px',
background: selectedId === snippet.id ? selectedBg : 'transparent',
borderLeft:
selectedId === snippet.id
? `3px solid ${overlayTheme.iconBg}`
: '3px solid transparent',
transition: 'all 0.15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden' }}>
<Typography.Text
code
style={{ fontSize: 12, flexShrink: 0, color: textColor }}
>
{snippet.prefix}
</Typography.Text>
<span
style={{
fontSize: 12,
color: textColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{snippet.name}
</span>
{snippet.isBuiltin && (
<Tag
style={{
fontSize: 10,
lineHeight: '16px',
padding: '0 4px',
margin: 0,
borderRadius: 4,
}}
color="blue"
>
</Tag>
)}
</div>
</List.Item>
)}
/>
</div>
<div style={{ padding: 8 }}>
<Button type="dashed" icon={<PlusOutlined />} block size="small" onClick={handleNew}>
</Button>
</div>
</div>
{/* Right: editor */}
<div style={{ flex: 1, minWidth: 0 }}>
{showEditor ? (
<div
style={{
...panelStyle,
display: 'flex',
flexDirection: 'column',
gap: 12,
height: '100%',
}}
>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 0.4 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input
value={draft.prefix}
onChange={(e) =>
setDraft((d) => ({ ...d, prefix: e.target.value.toLowerCase() }))
}
placeholder="如 sel, ins"
maxLength={20}
size="small"
/>
</div>
<div style={{ flex: 0.6 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="片段显示名称"
maxLength={60}
size="small"
/>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input
value={draft.description || ''}
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
placeholder="补全详情中的描述文字"
maxLength={200}
size="small"
/>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input.TextArea
value={draft.body}
onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
placeholder={'SELECT ${1:columns} FROM ${2:table_name}$0;'}
style={{
flex: 1,
minHeight: 120,
fontFamily: 'monospace',
fontSize: 13,
resize: 'none',
}}
/>
<Collapse
size="small"
items={syntaxHelpItems}
style={{ marginTop: 8, background: 'transparent' }}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
{draft.isBuiltin && draft.createdAt && (
<Popconfirm
title="重置为默认"
description="将恢复此内置片段的原始内容"
onConfirm={() => handleReset(draft.id)}
>
<Button icon={<UndoOutlined />} size="small">
</Button>
</Popconfirm>
)}
{!draft.isBuiltin && !isCreating && (
<Popconfirm
title="删除片段"
description="确定要删除此片段吗?"
onConfirm={() => handleDelete(draft.id)}
>
<Button danger icon={<DeleteOutlined />} size="small">
</Button>
</Popconfirm>
)}
<Button type="primary" icon={<SaveOutlined />} size="small" onClick={handleSave}>
</Button>
</div>
</div>
) : (
<div
style={{
...panelStyle,
display: 'grid',
placeItems: 'center',
height: '100%',
color: mutedColor,
fontSize: 13,
}}
>
</div>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -13,6 +13,7 @@ import {
ExternalSQLDirectory,
JVMDiagnosticCommandDraft,
JVMDiagnosticEventChunk,
SqlSnippet,
} from "./types";
import {
ShortcutAction,
@@ -23,6 +24,10 @@ import {
sanitizeShortcutOptions,
} from "./utils/shortcuts";
import { buildExternalSQLDirectoryId } from "./utils/externalSqlTree";
import {
DEFAULT_SQL_SNIPPETS,
BUILTIN_SNIPPET_MAP,
} from "./utils/sqlSnippetDefaults";
import { toPersistedGlobalProxy } from "./utils/globalProxyDraft";
import {
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
@@ -60,7 +65,7 @@ const DEFAULT_TIMEOUT_SECONDS = 30;
const MAX_TIMEOUT_SECONDS = 3600;
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
const PERSIST_VERSION = 8;
const PERSIST_VERSION = 9;
const PERSIST_STORAGE_KEY = "lite-db-storage";
const DEFAULT_CONNECTION_TYPE = "mysql";
const DEFAULT_JVM_PORT = 9010;
@@ -788,6 +793,7 @@ interface AppState {
sqlFormatOptions: { keywordCase: "upper" | "lower" };
queryOptions: QueryOptions;
shortcutOptions: ShortcutOptions;
sqlSnippets: SqlSnippet[];
sqlLogs: SqlLog[];
tableAccessCount: Record<string, number>;
tableSortPreference: Record<string, "name" | "frequency">;
@@ -875,6 +881,9 @@ interface AppState {
binding: Partial<ShortcutBinding>,
) => void;
resetShortcutOptions: () => void;
saveSqlSnippet: (snippet: SqlSnippet) => void;
deleteSqlSnippet: (id: string) => void;
resetBuiltinSqlSnippet: (id: string) => void;
addSqlLog: (log: SqlLog) => void;
clearSqlLogs: () => void;
@@ -967,6 +976,37 @@ const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
return result;
};
const sanitizeSqlSnippets = (value: unknown): SqlSnippet[] => {
if (!Array.isArray(value)) return DEFAULT_SQL_SNIPPETS;
const result: SqlSnippet[] = [];
const seenIds = new Set<string>();
value.forEach((entry, index) => {
if (!entry || typeof entry !== "object") return;
const raw = entry as Record<string, unknown>;
const prefix = toTrimmedString(raw.prefix)
.toLowerCase()
.replace(/[^a-z0-9_]/g, "")
.slice(0, 20);
const body = toTrimmedString(raw.body);
if (!prefix || !body) return;
const id = toTrimmedString(raw.id, `snippet-${index + 1}`) || `snippet-${index + 1}`;
if (seenIds.has(id)) return;
seenIds.add(id);
result.push({
id,
prefix,
name: toTrimmedString(raw.name, `片段-${index + 1}`) || `片段-${index + 1}`,
description: toTrimmedString(raw.description) || undefined,
body,
isBuiltin: raw.isBuiltin === true,
createdAt: Number.isFinite(Number(raw.createdAt))
? Number(raw.createdAt)
: Date.now(),
});
});
return result;
};
const sanitizeExternalSQLDirectories = (
value: unknown,
): ExternalSQLDirectory[] => {
@@ -1402,6 +1442,7 @@ export const useStore = create<AppState>()(
showColumnType: true,
},
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
sqlSnippets: DEFAULT_SQL_SNIPPETS,
sqlLogs: [],
tableAccessCount: {},
tableSortPreference: {},
@@ -1805,6 +1846,33 @@ export const useStore = create<AppState>()(
});
},
saveSqlSnippet: (snippet) =>
set((state) => {
const existing = state.sqlSnippets.findIndex((s) => s.id === snippet.id);
if (existing >= 0) {
const updated = [...state.sqlSnippets];
updated[existing] = snippet;
return { sqlSnippets: updated };
}
return { sqlSnippets: [...state.sqlSnippets, snippet] };
}),
deleteSqlSnippet: (id) =>
set((state) => ({
sqlSnippets: state.sqlSnippets.filter(
(s) => s.id !== id || s.isBuiltin,
),
})),
resetBuiltinSqlSnippet: (id) =>
set((state) => {
const original = BUILTIN_SNIPPET_MAP[id];
if (!original) return state;
return {
sqlSnippets: state.sqlSnippets.map((s) =>
s.id === id ? { ...original } : s,
),
};
}),
addSqlLog: (log) =>
set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
clearSqlLogs: () => set({ sqlLogs: [] }),
@@ -2140,6 +2208,15 @@ export const useStore = create<AppState>()(
nextState.shortcutOptions = sanitizeShortcutOptions(
state.shortcutOptions,
);
const existingSnippets = sanitizeSqlSnippets(state.sqlSnippets);
const existingSnippetIds = new Set(existingSnippets.map((s) => s.id));
const missingSnippets = DEFAULT_SQL_SNIPPETS.filter(
(d) => !existingSnippetIds.has(d.id),
);
nextState.sqlSnippets =
missingSnippets.length > 0
? [...existingSnippets, ...missingSnippets]
: existingSnippets;
nextState.tableAccessCount = sanitizeTableAccessCount(
state.tableAccessCount,
);
@@ -2204,6 +2281,7 @@ export const useStore = create<AppState>()(
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
queryOptions: sanitizeQueryOptions(state.queryOptions),
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
sqlSnippets: sanitizeSqlSnippets(state.sqlSnippets),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
// AI 会话数据不再从 localStorage 恢复,改为从后端文件加载
@@ -2228,6 +2306,7 @@ export const useStore = create<AppState>()(
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
sqlSnippets: state.sqlSnippets,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,

View File

@@ -454,6 +454,16 @@ export interface SavedQuery {
createdAt: number;
}
export interface SqlSnippet {
id: string;
prefix: string;
name: string;
description?: string;
body: string;
isBuiltin: boolean;
createdAt: number;
}
export interface ExternalSQLDirectory {
id: string;
name: string;

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_SQL_SNIPPETS, BUILTIN_SNIPPET_MAP } from './sqlSnippetDefaults';
import type { SqlSnippet } from '../types';
describe('sqlSnippetDefaults', () => {
it('DEFAULT_SQL_SNIPPETS should be a non-empty array', () => {
expect(Array.isArray(DEFAULT_SQL_SNIPPETS)).toBe(true);
expect(DEFAULT_SQL_SNIPPETS.length).toBeGreaterThan(0);
});
it('every default snippet should have required fields', () => {
for (const s of DEFAULT_SQL_SNIPPETS) {
expect(s.id).toBeTruthy();
expect(s.prefix).toBeTruthy();
expect(s.name).toBeTruthy();
expect(s.body).toBeTruthy();
expect(s.isBuiltin).toBe(true);
expect(typeof s.createdAt).toBe('number');
}
});
it('every prefix should be lowercase alphanumeric/underscore', () => {
for (const s of DEFAULT_SQL_SNIPPETS) {
expect(s.prefix).toMatch(/^[a-z0-9_]+$/);
}
});
it('prefixes should be unique', () => {
const prefixes = DEFAULT_SQL_SNIPPETS.map((s) => s.prefix);
expect(new Set(prefixes).size).toBe(prefixes.length);
});
it('ids should be unique', () => {
const ids = DEFAULT_SQL_SNIPPETS.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('all default snippets should have snippet syntax in body', () => {
for (const s of DEFAULT_SQL_SNIPPETS) {
const hasTabStopOrVariable = /\$\d|\$\{|CURRENT_/.test(s.body);
expect(hasTabStopOrVariable).toBe(true);
}
});
it('time-variable snippets should contain CURRENT_ markers', () => {
const seld = DEFAULT_SQL_SNIPPETS.find((s) => s.prefix === 'seld');
expect(seld).toBeDefined();
expect(seld!.body).toContain('CURRENT_YEAR');
expect(seld!.body).toContain('CURRENT_MONTH');
expect(seld!.body).toContain('CURRENT_DATE');
const inst = DEFAULT_SQL_SNIPPETS.find((s) => s.prefix === 'inst');
expect(inst).toBeDefined();
expect(inst!.body).toContain('CURRENT_HOUR');
expect(inst!.body).toContain('CURRENT_MINUTE');
expect(inst!.body).toContain('CURRENT_SECOND');
});
it('BUILTIN_SNIPPET_MAP should contain all default snippet ids', () => {
for (const s of DEFAULT_SQL_SNIPPETS) {
expect(BUILTIN_SNIPPET_MAP[s.id]).toBeDefined();
expect(BUILTIN_SNIPPET_MAP[s.id].prefix).toBe(s.prefix);
expect(BUILTIN_SNIPPET_MAP[s.id].body).toBe(s.body);
}
});
it('BUILTIN_SNIPPET_MAP entries should be independent copies', () => {
for (const s of DEFAULT_SQL_SNIPPETS) {
const mapped = BUILTIN_SNIPPET_MAP[s.id];
expect(mapped).not.toBe(s);
}
});
});

View File

@@ -0,0 +1,154 @@
import type { SqlSnippet } from "../types";
const builtinSnippets: Omit<SqlSnippet, "createdAt">[] = [
{
id: "builtin-sel",
prefix: "sel",
name: "SELECT 基本查询",
description: "基本 SELECT 查询模板",
body: "SELECT ${1:column_list} FROM ${2:table_name}$0;",
isBuiltin: true,
},
{
id: "builtin-selw",
prefix: "selw",
name: "SELECT WHERE",
description: "带 WHERE 条件的 SELECT 查询",
body: "SELECT ${1:columns} FROM ${2:table_name} WHERE ${3:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-selj",
prefix: "selj",
name: "SELECT JOIN",
description: "带 INNER JOIN 的 SELECT 查询",
body: "SELECT ${1:columns}\nFROM ${2:t1}\nINNER JOIN ${3:t2} ON ${4:t1.id} = ${5:t2.id}\nWHERE ${6:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-ins",
prefix: "ins",
name: "INSERT",
description: "INSERT 插入数据模板",
body: "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values})$0;",
isBuiltin: true,
},
{
id: "builtin-upd",
prefix: "upd",
name: "UPDATE",
description: "UPDATE 更新数据模板",
body: "UPDATE ${1:table_name}\nSET ${2:column} = ${3:value}\nWHERE ${4:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-del",
prefix: "del",
name: "DELETE",
description: "DELETE 删除数据模板",
body: "DELETE FROM ${1:table_name}\nWHERE ${2:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-ct",
prefix: "ct",
name: "CREATE TABLE",
description: "CREATE TABLE 建表模板",
body: "CREATE TABLE ${1:table_name} (\n ${2:id} INT PRIMARY KEY AUTO_INCREMENT,\n ${3:col} ${4:VARCHAR(255)} NOT NULL\n)$0;",
isBuiltin: true,
},
{
id: "builtin-alt",
prefix: "alt",
name: "ALTER TABLE",
description: "ALTER TABLE 添加列模板",
body: "ALTER TABLE ${1:table_name}\nADD COLUMN ${2:col} ${3:VARCHAR(255)}$0;",
isBuiltin: true,
},
{
id: "builtin-dro",
prefix: "dro",
name: "DROP TABLE",
description: "DROP TABLE 删表模板",
body: "DROP TABLE IF EXISTS ${1:table_name}$0;",
isBuiltin: true,
},
{
id: "builtin-grp",
prefix: "grp",
name: "GROUP BY",
description: "带 GROUP BY 的聚合查询模板",
body: "SELECT ${1:col}, COUNT(*)\nFROM ${2:table_name}\nGROUP BY ${1:col}$0;",
isBuiltin: true,
},
{
id: "builtin-ljo",
prefix: "ljo",
name: "LEFT JOIN",
description: "LEFT JOIN 左连接模板",
body: "LEFT JOIN ${1:t} ON ${2:left.col} = ${3:right.col}$0",
isBuiltin: true,
},
{
id: "builtin-sub",
prefix: "sub",
name: "子查询",
description: "IN 子查询模板",
body: "SELECT ${1:cols}\nFROM ${2:t1}\nWHERE ${3:col} IN (\n SELECT ${4:col} FROM ${5:t2} WHERE ${6:cond}\n)$0;",
isBuiltin: true,
},
{
id: "builtin-lim",
prefix: "lim",
name: "LIMIT 查询",
description: "带 LIMIT 的分页查询模板",
body: "SELECT ${1:cols} FROM ${2:table_name} LIMIT ${3:10}$0;",
isBuiltin: true,
},
{
id: "builtin-ord",
prefix: "ord",
name: "ORDER BY",
description: "带排序的查询模板",
body: "SELECT ${1:cols} FROM ${2:table_name} ORDER BY ${3:col} ${4|ASC,DESC|}$0;",
isBuiltin: true,
},
{
id: "builtin-seld",
prefix: "seld",
name: "SELECT 按日期查询",
description: "按日期条件过滤的 SELECT 查询,自动填入当天日期",
body: "SELECT ${1:cols} FROM ${2:table_name}\nWHERE ${3:date_col} >= '${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}'$0;",
isBuiltin: true,
},
{
id: "builtin-ctt",
prefix: "ctt",
name: "CREATE TABLE含时间列",
description: "建表模板,含 created_at / updated_at 时间列",
body: "CREATE TABLE ${1:table_name} (\n ${2:id} INT PRIMARY KEY AUTO_INCREMENT,\n ${3:col} ${4:VARCHAR(255)},\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n)$0;",
isBuiltin: true,
},
{
id: "builtin-inst",
prefix: "inst",
name: "INSERT含时间戳",
description: "INSERT 模板,自动填入当前时间戳",
body: "INSERT INTO ${1:table_name} (${2:columns}, created_at)\nVALUES (${3:values}, '${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} ${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}')$0;",
isBuiltin: true,
},
];
const now = Date.now();
export const DEFAULT_SQL_SNIPPETS: SqlSnippet[] = builtinSnippets.map(
(s, i) => ({
...s,
createdAt: now + i,
})
);
export const BUILTIN_SNIPPET_MAP: Record<string, SqlSnippet> = {};
for (const s of DEFAULT_SQL_SNIPPETS) {
BUILTIN_SNIPPET_MAP[s.id] = { ...s };
}

View File

@@ -246,4 +246,85 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}