388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import React, { useRef, useEffect, useCallback } from 'react';
|
|
import { EditorState, Compartment } from '@codemirror/state';
|
|
import type { Extension } from '@codemirror/state';
|
|
import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view';
|
|
import { sql, SQLDialect } from '@codemirror/lang-sql';
|
|
|
|
import { oneDark } from '@codemirror/theme-one-dark';
|
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
import {
|
|
syntaxHighlighting,
|
|
defaultHighlightStyle,
|
|
bracketMatching,
|
|
} from '@codemirror/language';
|
|
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface SqlEditorProps {
|
|
value: string;
|
|
onChange: (val: string) => void;
|
|
placeholder?: string;
|
|
language?: 'sql' | 'redis' | 'mongodb';
|
|
height?: string;
|
|
readOnly?: boolean;
|
|
onExecute?: () => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Language configs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const REDIS_KEYWORDS = [
|
|
'GET', 'SET', 'KEYS', 'HGET', 'HSET', 'HGETALL', 'HDEL', 'HMSET',
|
|
'ZADD', 'ZRANGE', 'ZREM', 'ZRANK', 'ZSCORE', 'ZCARD',
|
|
'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LRANGE', 'LLEN',
|
|
'SADD', 'SMEMBERS', 'SREM', 'SCARD', 'SISMEMBER',
|
|
'INFO', 'CLIENT', 'CONFIG', 'DEL', 'EXISTS', 'EXPIRE', 'TTL',
|
|
'TYPE', 'RENAME', 'DBSIZE', 'FLUSHDB', 'FLUSHALL',
|
|
'PING', 'ECHO', 'SELECT', 'MOVE', 'OBJECT', 'SORT',
|
|
'MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH',
|
|
'PUBLISH', 'SUBSCRIBE', 'UNSUBSCRIBE', 'PSUBSCRIBE',
|
|
'SCAN', 'HSCAN', 'SSCAN', 'ZSCAN',
|
|
'INCR', 'DECR', 'INCRBY', 'DECRBY', 'INCRBYFLOAT',
|
|
'APPEND', 'STRLEN', 'MGET', 'MSET', 'SETNX', 'SETEX',
|
|
'AUTH', 'QUIT', 'BGSAVE', 'SAVE', 'LASTSAVE',
|
|
'SLOWLOG', 'MONITOR', 'DEBUG', 'SHUTDOWN', 'SLAVEOF', 'REPLICAOF',
|
|
];
|
|
|
|
const MONGODB_KEYWORDS = [
|
|
// CRUD
|
|
'find', 'findOne', 'insertOne', 'insertMany', 'updateOne', 'updateMany',
|
|
'deleteOne', 'deleteMany', 'replaceOne', 'findOneAndUpdate', 'findOneAndDelete',
|
|
'findOneAndReplace', 'bulkWrite', 'aggregate', 'countDocuments', 'distinct',
|
|
// Aggregation stages
|
|
'match', 'group', 'project', 'sort', 'limit', 'skip', 'unwind', 'lookup',
|
|
'addFields', 'set', 'unset', 'facet', 'bucket', 'bucketAuto', 'sortByCount',
|
|
'graphLookup', 'out', 'merge', 'replaceRoot', 'sample', 'count',
|
|
// Operators
|
|
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'and', 'or', 'not',
|
|
'nor', 'exists', 'type', 'regex', 'expr', 'cond', 'switch', 'if', 'then',
|
|
'else', 'let', 'map', 'filter', 'reduce', 'range', 'concatArrays',
|
|
'avg', 'sum', 'min', 'max', 'push', 'addToSet', 'first', 'last',
|
|
// Index / admin
|
|
'createIndex', 'dropIndex', 'getIndexes', 'explain', 'hint',
|
|
// Collection / DB
|
|
'use', 'show', 'db', 'collection', 'collections', 'databases',
|
|
// JS keywords commonly used
|
|
'function', 'return', 'var', 'let', 'const', 'if', 'else', 'for', 'while',
|
|
'true', 'false', 'null', 'undefined', 'new', 'this', 'typeof', 'instanceof',
|
|
'ObjectId', 'Date', 'ISODate', 'NumberLong', 'NumberInt', 'NumberDecimal',
|
|
'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef',
|
|
];
|
|
|
|
function getSQLExtension(language: SqlEditorProps['language']): Extension {
|
|
const lang = language ?? 'sql';
|
|
|
|
if (lang === 'redis') {
|
|
const dialect = SQLDialect.define({
|
|
keywords: REDIS_KEYWORDS.join(' '),
|
|
});
|
|
return sql({ dialect });
|
|
}
|
|
|
|
if (lang === 'mongodb') {
|
|
const dialect = SQLDialect.define({
|
|
keywords: MONGODB_KEYWORDS.join(' '),
|
|
});
|
|
return sql({ dialect });
|
|
}
|
|
|
|
return sql();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom theme to match app CSS variables
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const appTheme = EditorView.theme({
|
|
'&': {
|
|
fontSize: '13px',
|
|
backgroundColor: 'var(--dg-bg-primary)',
|
|
color: 'var(--dg-text-primary)',
|
|
border: '1px solid var(--dg-border)',
|
|
borderRadius: '6px',
|
|
},
|
|
'.cm-content': {
|
|
caretColor: 'var(--dg-text-primary)',
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
|
|
},
|
|
'.cm-cursor': {
|
|
borderLeftColor: 'var(--dg-text-primary)',
|
|
},
|
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
|
backgroundColor: 'rgba(66, 133, 244, 0.3) !important',
|
|
},
|
|
'.cm-gutters': {
|
|
backgroundColor: 'var(--dg-bg-tertiary)',
|
|
color: 'var(--dg-text-muted)',
|
|
border: 'none',
|
|
borderRight: '1px solid var(--dg-border)',
|
|
},
|
|
'.cm-activeLineGutter': {
|
|
backgroundColor: 'var(--dg-bg-tertiary)',
|
|
},
|
|
'.cm-activeLine': {
|
|
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
|
},
|
|
'.cm-placeholder': {
|
|
color: 'var(--dg-text-muted)',
|
|
fontStyle: 'italic',
|
|
},
|
|
'.cm-scroller': {
|
|
overflow: 'auto',
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// formatSQL helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SQL_BREAK_KEYWORDS = [
|
|
'SELECT',
|
|
'FROM',
|
|
'WHERE',
|
|
'ORDER BY',
|
|
'GROUP BY',
|
|
'HAVING',
|
|
'LIMIT',
|
|
'OFFSET',
|
|
'JOIN',
|
|
'INNER JOIN',
|
|
'LEFT JOIN',
|
|
'RIGHT JOIN',
|
|
'FULL JOIN',
|
|
'CROSS JOIN',
|
|
'LEFT OUTER JOIN',
|
|
'RIGHT OUTER JOIN',
|
|
'FULL OUTER JOIN',
|
|
'ON',
|
|
'UNION',
|
|
'UNION ALL',
|
|
'INTERSECT',
|
|
'EXCEPT',
|
|
'INSERT INTO',
|
|
'VALUES',
|
|
'UPDATE',
|
|
'SET',
|
|
'DELETE FROM',
|
|
'CREATE TABLE',
|
|
'ALTER TABLE',
|
|
'DROP TABLE',
|
|
];
|
|
|
|
const SQL_KEYWORDS_UPPER = new Set([
|
|
'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET',
|
|
'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS', 'ON',
|
|
'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'AS',
|
|
'ASC', 'DESC', 'DISTINCT', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
|
|
'EXISTS', 'UNION', 'INTERSECT', 'EXCEPT',
|
|
'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'ALTER',
|
|
'DROP', 'TABLE', 'INDEX', 'VIEW', 'TRIGGER', 'PROCEDURE', 'FUNCTION',
|
|
'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'UNIQUE', 'CHECK', 'DEFAULT',
|
|
'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION',
|
|
'IF', 'ELSE', 'WHILE', 'FOR', 'RETURN',
|
|
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
|
|
'TRUE', 'FALSE', 'BOOLEAN', 'INTEGER', 'BIGINT', 'FLOAT', 'DOUBLE',
|
|
'VARCHAR', 'TEXT', 'DATE', 'TIMESTAMP', 'BLOB', 'JSON',
|
|
'TRUNCATE', 'EXPLAIN', 'ANALYZE', 'SHOW', 'DESCRIBE', 'USE', 'DATABASE',
|
|
]);
|
|
|
|
export function formatSQL(input: string): string {
|
|
// Normalise whitespace
|
|
let formatted = input.replace(/\s+/g, ' ').trim();
|
|
|
|
// Uppercase SQL keywords (word-boundary aware)
|
|
formatted = formatted.replace(/\b([A-Za-z_]+)\b/g, (match) => {
|
|
const upper = match.toUpperCase();
|
|
return SQL_KEYWORDS_UPPER.has(upper) ? upper : match;
|
|
});
|
|
|
|
// Insert newlines before major clauses
|
|
// We iterate in the order they might appear; use a single pass regex approach.
|
|
for (const kw of SQL_BREAK_KEYWORDS) {
|
|
// Only break on keyword that is not already at start of a line
|
|
const escaped = kw.replace(/\s+/g, '\\s+');
|
|
const re = new RegExp(`(?<!^)(?<!\\n)\\b(${escaped})\\b`, 'gi');
|
|
formatted = formatted.replace(re, '\n$1');
|
|
}
|
|
|
|
// Clean up any double newlines
|
|
formatted = formatted.replace(/\n{2,}/g, '\n');
|
|
|
|
return formatted.trim();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SqlEditor component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SqlEditor: React.FC<SqlEditorProps> = ({
|
|
value,
|
|
onChange,
|
|
placeholder = 'Enter SQL query…',
|
|
language = 'sql',
|
|
height = '200px',
|
|
readOnly = false,
|
|
onExecute,
|
|
}) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const viewRef = useRef<EditorView | null>(null);
|
|
const langCompartmentRef = useRef(new Compartment());
|
|
const readOnlyCompartmentRef = useRef(new Compartment());
|
|
const placeholderCompartmentRef = useRef(new Compartment());
|
|
const isExternalUpdateRef = useRef(false);
|
|
|
|
// Keep onChange in a stable ref so callbacks never go stale
|
|
const onChangeRef = useRef(onChange);
|
|
onChangeRef.current = onChange;
|
|
|
|
// Ctrl+Enter handler
|
|
const runKeymap = useCallback(
|
|
(): Extension[] => [
|
|
keymap.of([
|
|
{
|
|
key: 'Mod-Enter',
|
|
run: () => {
|
|
if (onExecute) {
|
|
onExecute();
|
|
} else {
|
|
window.dispatchEvent(new CustomEvent('sql-editor:execute'));
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
key: 'Mod-Shift-f',
|
|
run: (view: EditorView) => {
|
|
const current = view.state.doc.toString();
|
|
const formatted = formatSQL(current);
|
|
if (formatted !== current) {
|
|
view.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: view.state.doc.length,
|
|
insert: formatted,
|
|
},
|
|
});
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
]),
|
|
],
|
|
[onExecute],
|
|
);
|
|
|
|
// Create editor once
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
const updateListener = EditorView.updateListener.of((update) => {
|
|
if (update.docChanged && !isExternalUpdateRef.current) {
|
|
onChangeRef.current(update.state.doc.toString());
|
|
}
|
|
});
|
|
|
|
const state = EditorState.create({
|
|
doc: value,
|
|
extensions: [
|
|
lineNumbers(),
|
|
history(),
|
|
bracketMatching(),
|
|
closeBrackets(),
|
|
highlightSelectionMatches(),
|
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
appTheme,
|
|
oneDark,
|
|
langCompartmentRef.current.of(getSQLExtension(language)),
|
|
readOnlyCompartmentRef.current.of(
|
|
EditorState.readOnly.of(readOnly),
|
|
),
|
|
placeholderCompartmentRef.current.of(cmPlaceholder(placeholder)),
|
|
updateListener,
|
|
...runKeymap(),
|
|
keymap.of([
|
|
...closeBracketsKeymap,
|
|
...defaultKeymap,
|
|
...searchKeymap,
|
|
...historyKeymap,
|
|
]),
|
|
EditorView.lineWrapping,
|
|
],
|
|
});
|
|
|
|
const view = new EditorView({
|
|
state,
|
|
parent: containerRef.current,
|
|
});
|
|
|
|
viewRef.current = view;
|
|
|
|
return () => {
|
|
view.destroy();
|
|
viewRef.current = null;
|
|
};
|
|
// We intentionally only run this once on mount.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Sync external value changes into the editor (avoid loops)
|
|
useEffect(() => {
|
|
const view = viewRef.current;
|
|
if (!view) return;
|
|
|
|
const currentContent = view.state.doc.toString();
|
|
if (currentContent !== value) {
|
|
isExternalUpdateRef.current = true;
|
|
view.dispatch({
|
|
changes: { from: 0, to: view.state.doc.length, insert: value },
|
|
});
|
|
isExternalUpdateRef.current = false;
|
|
}
|
|
}, [value]);
|
|
|
|
// Update language when it changes
|
|
useEffect(() => {
|
|
const view = viewRef.current;
|
|
if (!view) return;
|
|
view.dispatch({
|
|
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language)),
|
|
});
|
|
}, [language]);
|
|
|
|
// Update readOnly when it changes
|
|
useEffect(() => {
|
|
const view = viewRef.current;
|
|
if (!view) return;
|
|
view.dispatch({
|
|
effects: readOnlyCompartmentRef.current.reconfigure(
|
|
EditorState.readOnly.of(readOnly),
|
|
),
|
|
});
|
|
}, [readOnly]);
|
|
|
|
// Update placeholder when it changes
|
|
useEffect(() => {
|
|
const view = viewRef.current;
|
|
if (!view) return;
|
|
view.dispatch({
|
|
effects: placeholderCompartmentRef.current.reconfigure(
|
|
cmPlaceholder(placeholder ?? ''),
|
|
),
|
|
});
|
|
}, [placeholder]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
style={{ height, minHeight: height }}
|
|
className="sql-editor-wrapper"
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default SqlEditor; |