mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 09:13:36 +08:00
✨ feat(query): 支持拖拽结果字段插入 SQL (#870)
## 变更说明 - 支持将查询结果集字段拖入 SQL 编辑器,并按光标位置、选区和字段上下文生成插入文本 - 复用同一次表头拖拽完成结果集内部列排序,不影响字段拖入查询区 - 使用结果集作用域隔离列排序,避免跨结果集误重排 - 鼠标使用原生 HTML 拖放,触摸和触控笔保留 dnd-kit PointerSensor 排序能力 ## 测试 - `npm test -- sqlFieldDrop.test.ts dataGridColumnOrder.test.ts`(19 个测试通过) - `npm run build` - Windows Wails 桌面端手动验证:结果集列排序、字段拖入查询区 Closes #848
This commit is contained in:
@@ -2899,6 +2899,11 @@ body[data-platform='windows'] .titlebar-window-controls {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.gonavi-query-editor-field-drop-anchor {
|
||||
background-color: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.gonavi-query-editor-db-token {
|
||||
color: #7c3aed;
|
||||
}
|
||||
@@ -2915,6 +2920,10 @@ body[data-theme='dark'] .gonavi-query-editor-column-token {
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-query-editor-field-drop-anchor {
|
||||
background-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-query-editor-db-token {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@ import {
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
horizontalListSortingStrategy,
|
||||
arrayMove
|
||||
horizontalListSortingStrategy
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
DATA_GRID_COLUMN_ORDER_DRAG_MIME,
|
||||
decodeDataGridColumnOrderDragPayload,
|
||||
hasDataGridColumnOrderDragPayload,
|
||||
moveDataGridColumnInVisibleOrder,
|
||||
} from './dataGridColumnOrder';
|
||||
import { ImportData, ExportDataWithOptions, ExportQueryWithOptions, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||
import ImportPreviewModal from './ImportPreviewModal';
|
||||
import { useStore } from '../store';
|
||||
@@ -576,42 +581,29 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
// 防御性检查:若正在调整列宽,忽略拖拽排序事件
|
||||
if (isResizingRef.current) return;
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id && over) {
|
||||
const columnOrderDragScopeRef = useRef(generateUuid());
|
||||
const reorderVisibleColumns = useCallback((sourceColumnName: string, targetColumnName: string) => {
|
||||
setAllOrderedColumnNames((prevAllOrder) => {
|
||||
// Calculate the new order of all columns by applying the movement
|
||||
// We only move the visible columns relative to each other, but the easiest way
|
||||
// is to map the visible column movement back to the full array.
|
||||
const hiddenSet = new Set(localHiddenColumns);
|
||||
const visibleOrder = prevAllOrder.filter(col => !hiddenSet.has(col));
|
||||
|
||||
const oldVisibleIndex = visibleOrder.indexOf(active.id as string);
|
||||
const newVisibleIndex = visibleOrder.indexOf(over.id as string);
|
||||
|
||||
if (oldVisibleIndex === -1 || newVisibleIndex === -1) return prevAllOrder;
|
||||
|
||||
const nextVisibleOrder = arrayMove(visibleOrder, oldVisibleIndex, newVisibleIndex);
|
||||
|
||||
// Reconstruct allOrderedColumnNames by inserting hidden columns back to their original relative positions
|
||||
// Or simpler: just keep hidden columns at the end, but that ruins user's layout.
|
||||
// Better approach: build a new array
|
||||
let vIndex = 0;
|
||||
const nextOrder = prevAllOrder.map(col => {
|
||||
if (hiddenSet.has(col)) {
|
||||
return col; // Hidden columns stay at their absolute index in the master list
|
||||
} else {
|
||||
return nextVisibleOrder[vIndex++];
|
||||
}
|
||||
});
|
||||
|
||||
const nextOrder = moveDataGridColumnInVisibleOrder(
|
||||
prevAllOrder,
|
||||
new Set(localHiddenColumns),
|
||||
sourceColumnName,
|
||||
targetColumnName,
|
||||
);
|
||||
if (nextOrder === prevAllOrder) return prevAllOrder;
|
||||
if (enableColumnOrderMemory && connectionId && dbName && tableName) {
|
||||
setTableColumnOrder(connectionId, dbName, tableName, nextOrder);
|
||||
}
|
||||
return nextOrder;
|
||||
});
|
||||
}, [connectionId, dbName, enableColumnOrderMemory, localHiddenColumns, setTableColumnOrder, tableName]);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
// 防御性检查:若正在调整列宽,忽略拖拽排序事件
|
||||
if (isResizingRef.current) return;
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id && over) {
|
||||
reorderVisibleColumns(String(active.id), String(over.id));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3106,11 +3098,28 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
},
|
||||
onHeaderCell: (column: any) => ({
|
||||
id: key,
|
||||
columnOrderDragScope: columnOrderDragScopeRef.current,
|
||||
width: column.width,
|
||||
className: `gonavi-sortable-header-cell${showColumnComment || showColumnType ? '' : ' is-single-line-title'}`,
|
||||
'data-i18n-language': language,
|
||||
onResizeStart: handleResizeStart(key), // Only need start
|
||||
onResizeAutoFit: handleResizeAutoFit(key),
|
||||
onDragOver: (event: React.DragEvent<HTMLElement>) => {
|
||||
if (!hasDataGridColumnOrderDragPayload(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
},
|
||||
onDrop: (event: React.DragEvent<HTMLElement>) => {
|
||||
if (!hasDataGridColumnOrderDragPayload(event.dataTransfer)) return;
|
||||
const payload = decodeDataGridColumnOrderDragPayload(
|
||||
event.dataTransfer.getData(DATA_GRID_COLUMN_ORDER_DRAG_MIME),
|
||||
);
|
||||
if (!payload || payload.scope !== columnOrderDragScopeRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
reorderVisibleColumns(payload.columnName, key);
|
||||
},
|
||||
onContextMenu: (event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!isV2Ui) return;
|
||||
showColumnHeaderContextMenu(event, key);
|
||||
@@ -3151,7 +3160,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
},
|
||||
}),
|
||||
}));
|
||||
}, [canModifyData, cellEditMode, columnWidths, currentConnConfig, dataTableDensity, displayColumnNames, displayColumnTypeMap, effectiveEditLocator, enableVirtual, handleResizeAutoFit, handleResizeStart, isV2Ui, language, normalizedPageFindText, onSort, pinnedLeftColumnSet, renderColumnTitle, selectEditableColumnCells, showColumnComment, showColumnHeaderContextMenu, showColumnType, sortInfo]);
|
||||
}, [canModifyData, cellEditMode, columnWidths, currentConnConfig, dataTableDensity, displayColumnNames, displayColumnTypeMap, effectiveEditLocator, enableVirtual, handleResizeAutoFit, handleResizeStart, isV2Ui, language, normalizedPageFindText, onSort, pinnedLeftColumnSet, renderColumnTitle, reorderVisibleColumns, selectEditableColumnCells, showColumnComment, showColumnHeaderContextMenu, showColumnType, sortInfo]);
|
||||
|
||||
const mergedColumns = useMemo(() => columns.map((col): ColumnType<any> => {
|
||||
const dataIndex = String(col.dataIndex);
|
||||
|
||||
@@ -75,6 +75,13 @@ import {
|
||||
import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
|
||||
import { formatMongoValueForDisplay } from '../utils/mongodb';
|
||||
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag';
|
||||
import { SQL_FIELD_DRAG_MIME } from '../utils/sqlFieldDrop';
|
||||
import {
|
||||
DATA_GRID_COLUMN_ORDER_DRAG_MIME,
|
||||
encodeDataGridColumnOrderDragPayload,
|
||||
shouldBypassDndKitForNativeColumnHeaderDrag,
|
||||
} from './dataGridColumnOrder';
|
||||
import {
|
||||
TEMPORAL_FORMATS,
|
||||
formatFromDayjs,
|
||||
@@ -782,6 +789,7 @@ const ResizableTitle = React.forwardRef<HTMLTableCellElement, any>((props, ref)
|
||||
// --- Sortable Header Cell ---
|
||||
interface SortableHeaderCellProps extends React.HTMLAttributes<HTMLTableCellElement> {
|
||||
id?: string;
|
||||
columnOrderDragScope?: string;
|
||||
}
|
||||
|
||||
// --- Sortable Header Cell ---
|
||||
@@ -815,7 +823,7 @@ const sortableHeaderStaticStyles = `
|
||||
`;
|
||||
|
||||
const SortableHeaderCell: React.FC<SortableHeaderCellProps> = React.memo((props) => {
|
||||
const { id, children, style: propStyle, className: propClassName, ...restProps } = props;
|
||||
const { id, children, style: propStyle, className: propClassName, columnOrderDragScope, ...restProps } = props;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const {
|
||||
attributes,
|
||||
@@ -873,11 +881,43 @@ const SortableHeaderCell: React.FC<SortableHeaderCellProps> = React.memo((props)
|
||||
{...listeners}
|
||||
onPointerDown={(e: any) => {
|
||||
setIsPressed(true);
|
||||
if (
|
||||
(e.target as HTMLElement | null)?.closest?.('.sortable-header-cell-drag-handle')
|
||||
&& shouldBypassDndKitForNativeColumnHeaderDrag(e.pointerType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (listeners?.onPointerDown) listeners.onPointerDown(e);
|
||||
}}
|
||||
>
|
||||
<style>{sortableHeaderStaticStyles}</style>
|
||||
<div className="sortable-header-cell-drag-handle" title={t('data_grid.column.drag_tooltip')}>
|
||||
<div
|
||||
className="sortable-header-cell-drag-handle"
|
||||
title={t('data_grid.column.drag_tooltip')}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
const columnName = String(id || '').trim();
|
||||
if (!columnName || !event.dataTransfer) return;
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
const payload = encodeSidebarSqlEditorDragPayload({
|
||||
text: columnName,
|
||||
nodeType: 'column',
|
||||
});
|
||||
event.dataTransfer.setData(SIDEBAR_SQL_EDITOR_DRAG_MIME, payload);
|
||||
event.dataTransfer.setData(SQL_FIELD_DRAG_MIME, columnName);
|
||||
if (columnOrderDragScope) {
|
||||
event.dataTransfer.setData(
|
||||
DATA_GRID_COLUMN_ORDER_DRAG_MIME,
|
||||
encodeDataGridColumnOrderDragPayload({
|
||||
scope: columnOrderDragScope,
|
||||
columnName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
event.dataTransfer.setData('text/plain', columnName);
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', minWidth: 0, cursor: 'inherit' }}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -4299,6 +4299,89 @@ describe('QueryEditor external SQL save', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('projects field drops from editor whitespace by x coordinate and previews the same anchor', async () => {
|
||||
const domListeners: Record<string, ((event?: any) => void)[]> = {};
|
||||
const sql = 'SELECT org_id, title FROM a_cninfo_announcement\n\n';
|
||||
editorState.domNode = {
|
||||
style: { cursor: '' },
|
||||
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
|
||||
domListeners[type] ||= [];
|
||||
domListeners[type].push(listener);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
contains: vi.fn(() => false),
|
||||
getBoundingClientRect: vi.fn(() => ({ left: 0, top: 0, width: 800, height: 300 })),
|
||||
} as any;
|
||||
editorState.editor.getTargetAtClientPoint = vi.fn(() => ({
|
||||
type: 7,
|
||||
position: { lineNumber: 3, column: 1 },
|
||||
}));
|
||||
editorState.editor.getVisibleRanges = vi.fn(() => [{ startLineNumber: 1, endLineNumber: 3 }]);
|
||||
editorState.editor.getScrolledVisiblePosition = vi.fn(({ lineNumber, column }: any) => ({
|
||||
left: (column - 1) * 10,
|
||||
top: (lineNumber - 1) * 20,
|
||||
height: 20,
|
||||
}));
|
||||
editorState.editor.render = vi.fn();
|
||||
editorState.value = sql;
|
||||
|
||||
await act(async () => {
|
||||
create(<QueryEditor tab={createTab({ query: sql })} />);
|
||||
});
|
||||
|
||||
const titleOffset = sql.indexOf('title');
|
||||
const createDataTransfer = () => ({
|
||||
types: [
|
||||
'application/x-gonavi-sql-object',
|
||||
'application/x-gonavi-sql-field',
|
||||
'text/plain',
|
||||
],
|
||||
dropEffect: 'none',
|
||||
getData: (type: string) => {
|
||||
if (type === 'application/x-gonavi-sql-object') {
|
||||
return JSON.stringify({ text: 'announcement_id', nodeType: 'column' });
|
||||
}
|
||||
return 'announcement_id';
|
||||
},
|
||||
});
|
||||
const dragCoordinates = {
|
||||
clientX: (titleOffset + 2) * 10,
|
||||
clientY: 100,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
domListeners.dragover?.forEach((listener) => listener({
|
||||
...dragCoordinates,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: createDataTransfer(),
|
||||
}));
|
||||
});
|
||||
|
||||
const previewDecoration = editorState.editor.deltaDecorations.mock.calls
|
||||
.flatMap((call: any[]) => call[1] || [])
|
||||
.find((decoration: any) => decoration?.options?.inlineClassName === 'gonavi-query-editor-field-drop-anchor');
|
||||
expect(previewDecoration?.range).toMatchObject({
|
||||
startLineNumber: 1,
|
||||
startColumn: titleOffset + 1,
|
||||
endLineNumber: 1,
|
||||
endColumn: titleOffset + 'title'.length + 1,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
domListeners.drop?.forEach((listener) => listener({
|
||||
...dragCoordinates,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: createDataTransfer(),
|
||||
}));
|
||||
});
|
||||
|
||||
expect(editorState.value).toBe(
|
||||
'SELECT org_id, title, announcement_id FROM a_cninfo_announcement\n\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches database and completion metadata only for the active query tab', async () => {
|
||||
autoFetchState.visible = true;
|
||||
backendApp.DBGetDatabases.mockResolvedValue({
|
||||
|
||||
@@ -50,6 +50,12 @@ import { isMacLikePlatform } from '../utils/appearance';
|
||||
import { splitSidebarQualifiedName } from '../utils/sidebarLocate';
|
||||
import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName } from '../utils/sidebarMetadata';
|
||||
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag';
|
||||
import {
|
||||
buildSqlFieldDropEdit,
|
||||
hasSqlFieldDragPayload,
|
||||
resolveSqlFieldDropAnchorRange,
|
||||
resolveSqlFieldDropCursorOffset,
|
||||
} from '../utils/sqlFieldDrop';
|
||||
import {
|
||||
CLOSE_ACTIVE_RESULT_TAB_EVENT,
|
||||
type CloseActiveResultShortcutRequest,
|
||||
@@ -1548,6 +1554,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const linkDecorationIdsRef = useRef<string[]>([]);
|
||||
const ctrlMetaPressedRef = useRef(false);
|
||||
const objectDecorationIdsRef = useRef<string[]>([]);
|
||||
const sqlFieldDropDecorationIdsRef = useRef<string[]>([]);
|
||||
const aiInlineGhostDecorationIdsRef = useRef<string[]>([]);
|
||||
const aiInlineGhostOverlayRef = useRef<HTMLSpanElement | null>(null);
|
||||
const aiInlineGhostVisibleContextKeyRef = useRef<any>(null);
|
||||
@@ -3045,6 +3052,113 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const resolveSqlFieldDropPosition = useCallback((editor: any, event: DragEvent) => {
|
||||
const model = editor?.getModel?.();
|
||||
if (!editor || !model) return null;
|
||||
|
||||
const monacoTarget = editor.getTargetAtClientPoint?.(event.clientX, event.clientY);
|
||||
// CONTENT_EMPTY 会把文字下方的鼠标位置钳制到行尾,必须保留横坐标重新投影。
|
||||
let position = Number(monacoTarget?.type) === 6
|
||||
? normalizeEditorPosition(monacoTarget?.position)
|
||||
: null;
|
||||
if (!position) {
|
||||
const editorDomNode = editor.getDomNode?.() as HTMLElement | null;
|
||||
const bounds = editorDomNode?.getBoundingClientRect?.();
|
||||
const visibleRanges = editor.getVisibleRanges?.() || [];
|
||||
if (bounds && visibleRanges.length > 0) {
|
||||
const localX = event.clientX - bounds.left;
|
||||
const localY = event.clientY - bounds.top;
|
||||
const visibleLines: number[] = [];
|
||||
visibleRanges.forEach((range: any) => {
|
||||
const startLine = Math.max(1, Number(range?.startLineNumber || 1));
|
||||
const endLine = Math.max(startLine, Number(range?.endLineNumber || startLine));
|
||||
for (let lineNumber = startLine; lineNumber <= endLine; lineNumber += 1) {
|
||||
if (!visibleLines.includes(lineNumber)) visibleLines.push(lineNumber);
|
||||
}
|
||||
});
|
||||
const nonEmptyLines = visibleLines.filter((lineNumber) => (
|
||||
String(model.getLineContent?.(lineNumber) || '').trim().length > 0
|
||||
));
|
||||
const candidateLines = nonEmptyLines.length > 0 ? nonEmptyLines : visibleLines;
|
||||
let nearestLine = Number(candidateLines[0] || 1);
|
||||
let nearestLineDistance = Number.POSITIVE_INFINITY;
|
||||
candidateLines.forEach((lineNumber) => {
|
||||
const visible = editor.getScrolledVisiblePosition?.({ lineNumber, column: 1 });
|
||||
if (!visible) return;
|
||||
const distance = Math.abs(localY - (visible.top + visible.height / 2));
|
||||
if (distance < nearestLineDistance) {
|
||||
nearestLine = lineNumber;
|
||||
nearestLineDistance = distance;
|
||||
}
|
||||
});
|
||||
|
||||
const maxColumn = Math.max(1, Number(model.getLineMaxColumn?.(nearestLine) || 1));
|
||||
let low = 1;
|
||||
let high = maxColumn;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const visible = editor.getScrolledVisiblePosition?.({ lineNumber: nearestLine, column: middle });
|
||||
if (!visible || visible.left < localX) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const candidateColumns = [Math.max(1, low - 1), low, Math.min(maxColumn, low + 1)];
|
||||
const nearestColumn = candidateColumns.reduce((best, column) => {
|
||||
const bestVisible = editor.getScrolledVisiblePosition?.({ lineNumber: nearestLine, column: best });
|
||||
const candidateVisible = editor.getScrolledVisiblePosition?.({ lineNumber: nearestLine, column });
|
||||
if (!candidateVisible) return best;
|
||||
if (!bestVisible) return column;
|
||||
return Math.abs(candidateVisible.left - localX) < Math.abs(bestVisible.left - localX)
|
||||
? column
|
||||
: best;
|
||||
}, candidateColumns[0]);
|
||||
position = normalizeEditorPosition({ lineNumber: nearestLine, column: nearestColumn });
|
||||
}
|
||||
}
|
||||
position = position
|
||||
|| normalizeEditorPosition(editor.getPosition?.())
|
||||
|| normalizeEditorPosition(lastEditorCursorPositionRef.current);
|
||||
if (!position) return null;
|
||||
|
||||
const rawOffset = Number(model.getOffsetAt?.(position));
|
||||
if (!Number.isFinite(rawOffset) || typeof model.getPositionAt !== 'function') return position;
|
||||
return normalizeEditorPosition(model.getPositionAt(
|
||||
resolveSqlFieldDropCursorOffset(String(model.getValue?.() || ''), rawOffset),
|
||||
)) || position;
|
||||
}, []);
|
||||
|
||||
const clearSqlFieldDropPreview = useCallback((editor: any) => {
|
||||
if (!editor?.deltaDecorations) {
|
||||
sqlFieldDropDecorationIdsRef.current = [];
|
||||
return;
|
||||
}
|
||||
sqlFieldDropDecorationIdsRef.current = editor.deltaDecorations(
|
||||
sqlFieldDropDecorationIdsRef.current,
|
||||
[],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateSqlFieldDropPreview = useCallback((editor: any, position: any) => {
|
||||
const model = editor?.getModel?.();
|
||||
const monaco = monacoRef.current;
|
||||
const offset = Number(model?.getOffsetAt?.(position));
|
||||
const anchor = model && Number.isFinite(offset)
|
||||
? resolveSqlFieldDropAnchorRange(String(model.getValue?.() || ''), offset)
|
||||
: null;
|
||||
if (!anchor || !monaco?.Range || typeof model?.getPositionAt !== 'function') {
|
||||
clearSqlFieldDropPreview(editor);
|
||||
return;
|
||||
}
|
||||
const start = model.getPositionAt(anchor.startOffset);
|
||||
const end = model.getPositionAt(anchor.endOffset);
|
||||
sqlFieldDropDecorationIdsRef.current = editor.deltaDecorations(
|
||||
sqlFieldDropDecorationIdsRef.current,
|
||||
[{
|
||||
range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column),
|
||||
options: { inlineClassName: 'gonavi-query-editor-field-drop-anchor' },
|
||||
}],
|
||||
);
|
||||
}, [clearSqlFieldDropPreview]);
|
||||
|
||||
const mergeSidebarDropObjectMetadata = useCallback((payload: ReturnType<typeof decodeSidebarSqlEditorDragPayload>) => {
|
||||
if (!payload?.text || !payload.dbName) {
|
||||
return;
|
||||
@@ -3092,12 +3206,42 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return;
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
const dropTarget = editor?.getTargetAtClientPoint?.(event.clientX, event.clientY);
|
||||
if (insertTextIntoEditorAtPosition(dragText, normalizeEditorPosition(dropTarget?.position))) {
|
||||
clearSqlFieldDropPreview(editor);
|
||||
const payloadNodeType = String(payload?.nodeType || '').trim().toLowerCase();
|
||||
const targetPosition = payloadNodeType === 'column'
|
||||
? resolveSqlFieldDropPosition(editor, event)
|
||||
: normalizeEditorPosition(editor?.getTargetAtClientPoint?.(event.clientX, event.clientY)?.position)
|
||||
|| normalizeEditorPosition(editor?.getPosition?.())
|
||||
|| normalizeEditorPosition(lastEditorCursorPositionRef.current);
|
||||
let inserted = false;
|
||||
if (payloadNodeType === 'column' && editor && targetPosition) {
|
||||
const model = editor.getModel?.();
|
||||
const monaco = monacoRef.current;
|
||||
const offset = Number(model?.getOffsetAt?.(targetPosition));
|
||||
const edit = model && monaco?.Range && typeof model.getPositionAt === 'function' && Number.isFinite(offset)
|
||||
? buildSqlFieldDropEdit({ sql: String(model?.getValue?.() || ''), offset, fieldName: dragText })
|
||||
: null;
|
||||
if (edit) {
|
||||
const start = model.getPositionAt(edit.startOffset);
|
||||
const end = model.getPositionAt(edit.endOffset);
|
||||
editor.focus?.();
|
||||
editor.setPosition?.(targetPosition);
|
||||
editor.executeEdits?.('gonavi-result-field-drop', [{
|
||||
range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column),
|
||||
text: edit.text,
|
||||
forceMoveMarkers: true,
|
||||
}]);
|
||||
editor.pushUndoStop?.();
|
||||
inserted = true;
|
||||
}
|
||||
} else {
|
||||
inserted = insertTextIntoEditorAtPosition(dragText, targetPosition);
|
||||
}
|
||||
if (inserted) {
|
||||
mergeSidebarDropObjectMetadata(payload);
|
||||
refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH);
|
||||
}
|
||||
}, [insertTextIntoEditorAtPosition, mergeSidebarDropObjectMetadata, refreshObjectDecorations]);
|
||||
}, [clearSqlFieldDropPreview, insertTextIntoEditorAtPosition, mergeSidebarDropObjectMetadata, refreshObjectDecorations, resolveSqlFieldDropPosition]);
|
||||
|
||||
const handleSelectCurrentStatement = async () => {
|
||||
const editor = editorRef.current;
|
||||
@@ -5150,6 +5294,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
if (hasSqlFieldDragPayload(event.dataTransfer)) {
|
||||
const dropPosition = resolveSqlFieldDropPosition(editor, event);
|
||||
if (dropPosition) {
|
||||
editor.setPosition?.(dropPosition);
|
||||
lastEditorCursorPositionRef.current = dropPosition;
|
||||
updateSqlFieldDropPreview(editor, dropPosition);
|
||||
editor.render?.(false);
|
||||
} else {
|
||||
clearSqlFieldDropPreview(editor);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleEditorDragLeave = (rawEvent: Event) => {
|
||||
const relatedTarget = (rawEvent as DragEvent).relatedTarget as Node | null;
|
||||
if (relatedTarget && editorDomNode?.contains?.(relatedTarget)) return;
|
||||
clearSqlFieldDropPreview(editor);
|
||||
};
|
||||
const handleSqlFieldDragEnd = () => {
|
||||
clearSqlFieldDropPreview(editor);
|
||||
};
|
||||
const handleEditorDrop = (rawEvent: Event) => {
|
||||
handleSidebarObjectDrop(rawEvent as DragEvent);
|
||||
@@ -5389,10 +5552,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
window.addEventListener('keydown', syncModifierState);
|
||||
window.addEventListener('keyup', syncModifierState);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
window.addEventListener('dragend', handleSqlFieldDragEnd);
|
||||
window.addEventListener('drop', handleSqlFieldDragEnd);
|
||||
editorDomNode?.addEventListener('beforeinput', handleImeBeforeInput, true);
|
||||
editorDomNode?.addEventListener('compositionstart', handleImeCompositionStart, true);
|
||||
editorDomNode?.addEventListener('compositionend', handleImeCompositionEnd, true);
|
||||
editorDomNode?.addEventListener('dragover', handleEditorDragOver, true);
|
||||
editorDomNode?.addEventListener('dragleave', handleEditorDragLeave, true);
|
||||
editorDomNode?.addEventListener('drop', handleEditorDrop, true);
|
||||
|
||||
editor.onMouseDown?.((event: any) => {
|
||||
@@ -5564,6 +5730,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
editor.onDidDispose?.(() => {
|
||||
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
|
||||
clearQueryEditorObjectDecorations(editor, objectDecorationIdsRef);
|
||||
clearSqlFieldDropPreview(editor);
|
||||
setQueryEditorMouseCursor(editor, '');
|
||||
objectHoverActionRef.current?.dispose?.();
|
||||
objectHoverActionRef.current = null;
|
||||
@@ -5586,11 +5753,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
window.removeEventListener('keydown', syncModifierState);
|
||||
window.removeEventListener('keyup', syncModifierState);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
window.removeEventListener('dragend', handleSqlFieldDragEnd);
|
||||
window.removeEventListener('drop', handleSqlFieldDragEnd);
|
||||
clearImeCompositionFallbackTimer();
|
||||
editorDomNode?.removeEventListener('beforeinput', handleImeBeforeInput, true);
|
||||
editorDomNode?.removeEventListener('compositionstart', handleImeCompositionStart, true);
|
||||
editorDomNode?.removeEventListener('compositionend', handleImeCompositionEnd, true);
|
||||
editorDomNode?.removeEventListener('dragover', handleEditorDragOver, true);
|
||||
editorDomNode?.removeEventListener('dragleave', handleEditorDragLeave, true);
|
||||
editorDomNode?.removeEventListener('drop', handleEditorDrop, true);
|
||||
});
|
||||
|
||||
|
||||
43
frontend/src/components/dataGridColumnOrder.test.ts
Normal file
43
frontend/src/components/dataGridColumnOrder.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DATA_GRID_COLUMN_ORDER_DRAG_MIME,
|
||||
decodeDataGridColumnOrderDragPayload,
|
||||
encodeDataGridColumnOrderDragPayload,
|
||||
hasDataGridColumnOrderDragPayload,
|
||||
moveDataGridColumnInVisibleOrder,
|
||||
shouldBypassDndKitForNativeColumnHeaderDrag,
|
||||
} from './dataGridColumnOrder';
|
||||
|
||||
describe('dataGridColumnOrder helpers', () => {
|
||||
it('reorders a dragged visible column at the header drop target while hidden columns keep their slot', () => {
|
||||
expect(moveDataGridColumnInVisibleOrder(
|
||||
['id', 'hidden_note', 'name', 'code'],
|
||||
new Set(['hidden_note']),
|
||||
'code',
|
||||
'id',
|
||||
)).toEqual(['code', 'hidden_note', 'id', 'name']);
|
||||
});
|
||||
|
||||
it('keeps the column reorder payload scoped to its source result set', () => {
|
||||
const payload = encodeDataGridColumnOrderDragPayload({
|
||||
scope: 'result-set-1',
|
||||
columnName: 'title',
|
||||
});
|
||||
|
||||
expect(decodeDataGridColumnOrderDragPayload(payload)).toEqual({
|
||||
scope: 'result-set-1',
|
||||
columnName: 'title',
|
||||
});
|
||||
expect(hasDataGridColumnOrderDragPayload({
|
||||
types: [DATA_GRID_COLUMN_ORDER_DRAG_MIME],
|
||||
})).toBe(true);
|
||||
expect(decodeDataGridColumnOrderDragPayload('{')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps touch and pen pointer input on dnd-kit instead of native HTML drag', () => {
|
||||
expect(shouldBypassDndKitForNativeColumnHeaderDrag('mouse')).toBe(true);
|
||||
expect(shouldBypassDndKitForNativeColumnHeaderDrag('touch')).toBe(false);
|
||||
expect(shouldBypassDndKitForNativeColumnHeaderDrag('pen')).toBe(false);
|
||||
});
|
||||
});
|
||||
57
frontend/src/components/dataGridColumnOrder.ts
Normal file
57
frontend/src/components/dataGridColumnOrder.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
||||
export const DATA_GRID_COLUMN_ORDER_DRAG_MIME = 'application/x-gonavi-data-grid-column-order';
|
||||
|
||||
export type DataGridColumnOrderDragPayload = {
|
||||
scope: string;
|
||||
columnName: string;
|
||||
};
|
||||
|
||||
export const encodeDataGridColumnOrderDragPayload = (
|
||||
payload: DataGridColumnOrderDragPayload,
|
||||
): string => JSON.stringify(payload);
|
||||
|
||||
export const decodeDataGridColumnOrderDragPayload = (
|
||||
rawPayload: string,
|
||||
): DataGridColumnOrderDragPayload | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(rawPayload || ''));
|
||||
const scope = String(parsed?.scope || '').trim();
|
||||
const columnName = String(parsed?.columnName || '').trim();
|
||||
return scope && columnName ? { scope, columnName } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const hasDataGridColumnOrderDragPayload = (
|
||||
dataTransfer: Pick<DataTransfer, 'types'> | null | undefined,
|
||||
): boolean => Array.from(dataTransfer?.types || [])
|
||||
.some((type) => String(type || '').toLowerCase() === DATA_GRID_COLUMN_ORDER_DRAG_MIME);
|
||||
|
||||
// Native HTML drag handles mouse reliably; touch and pen need dnd-kit's PointerSensor.
|
||||
export const shouldBypassDndKitForNativeColumnHeaderDrag = (pointerType: string): boolean => (
|
||||
pointerType === 'mouse'
|
||||
);
|
||||
|
||||
export const moveDataGridColumnInVisibleOrder = (
|
||||
allColumnNames: string[],
|
||||
hiddenColumnNames: ReadonlySet<string>,
|
||||
sourceColumnName: string,
|
||||
targetColumnName: string,
|
||||
): string[] => {
|
||||
const source = String(sourceColumnName || '').trim();
|
||||
const target = String(targetColumnName || '').trim();
|
||||
if (!source || !target || source === target) return allColumnNames;
|
||||
|
||||
const visibleColumnNames = allColumnNames.filter((columnName) => !hiddenColumnNames.has(columnName));
|
||||
const sourceIndex = visibleColumnNames.indexOf(source);
|
||||
const targetIndex = visibleColumnNames.indexOf(target);
|
||||
if (sourceIndex < 0 || targetIndex < 0) return allColumnNames;
|
||||
|
||||
const nextVisibleColumnNames = arrayMove(visibleColumnNames, sourceIndex, targetIndex);
|
||||
let visibleIndex = 0;
|
||||
return allColumnNames.map((columnName) => (
|
||||
hiddenColumnNames.has(columnName) ? columnName : nextVisibleColumnNames[visibleIndex++]
|
||||
));
|
||||
};
|
||||
163
frontend/src/utils/sqlFieldDrop.test.ts
Normal file
163
frontend/src/utils/sqlFieldDrop.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildSqlFieldDropEdit,
|
||||
resolveSqlFieldDropAnchorRange,
|
||||
resolveSqlFieldDropCursorOffset,
|
||||
} from './sqlFieldDrop';
|
||||
|
||||
const applyEdit = (sql: string, offset: number, fieldName: string): string => {
|
||||
const edit = buildSqlFieldDropEdit({ sql, offset, fieldName });
|
||||
if (!edit) return sql;
|
||||
return `${sql.slice(0, edit.startOffset)}${edit.text}${sql.slice(edit.endOffset)}`;
|
||||
};
|
||||
|
||||
describe('buildSqlFieldDropEdit', () => {
|
||||
it('replaces a select star', () => {
|
||||
const sql = 'select * from users';
|
||||
expect(applyEdit(sql, sql.indexOf('*'), 'name')).toBe('select name from users');
|
||||
});
|
||||
|
||||
it('inserts directly after select', () => {
|
||||
const sql = 'select from users';
|
||||
expect(applyEdit(sql, sql.indexOf('from'), 'name')).toBe('select name from users');
|
||||
});
|
||||
|
||||
it('adds a comma after an existing select field', () => {
|
||||
const sql = 'select id from users';
|
||||
expect(applyEdit(sql, sql.indexOf('from'), 'name')).toBe('select id, name from users');
|
||||
});
|
||||
|
||||
it('does not duplicate a comma', () => {
|
||||
const sql = 'select id, from users';
|
||||
expect(applyEdit(sql, sql.indexOf('from'), 'name')).toBe('select id, name from users');
|
||||
});
|
||||
|
||||
it('adds a comma when dropped immediately after an existing field', () => {
|
||||
const sql = 'select id from users';
|
||||
expect(applyEdit(sql, sql.indexOf(' from'), 'name')).toBe('select id, name from users');
|
||||
});
|
||||
|
||||
it('supports insert column lists and update set lists', () => {
|
||||
const insertSql = 'insert into users () values ()';
|
||||
expect(applyEdit(insertSql, insertSql.indexOf('(') + 1, 'name')).toBe('insert into users (name) values ()');
|
||||
expect(applyEdit('update users set where id = 1', 17, 'name')).toBe('update users set name where id = 1');
|
||||
const updateSql = 'update users set id = 1 where name = ?';
|
||||
expect(applyEdit(updateSql, updateSql.indexOf(' where'), 'enabled')).toBe('update users set id = 1, enabled where name = ?');
|
||||
|
||||
const populatedInsertSql = 'insert into users (id, name) values (?, ?)';
|
||||
expect(applyEdit(populatedInsertSql, populatedInsertSql.indexOf('id') + 1, 'enabled'))
|
||||
.toBe('insert into users (id, enabled, name) values (?, ?)');
|
||||
expect(applyEdit(populatedInsertSql, populatedInsertSql.indexOf('name') + 1, 'name'))
|
||||
.toBe(populatedInsertSql);
|
||||
});
|
||||
|
||||
it('does not treat update FROM and OUTPUT clauses as SET lists', () => {
|
||||
const postgresSql = 'UPDATE users SET active = source.active FROM source WHERE users.id = source.user_id';
|
||||
expect(applyEdit(postgresSql, postgresSql.indexOf(' WHERE'), 'archived_at'))
|
||||
.toBe('UPDATE users SET active = source.active FROM source archived_at WHERE users.id = source.user_id');
|
||||
|
||||
const sqlServerOutputSql = 'UPDATE users SET active = 1 OUTPUT FROM users WHERE users.id = 1';
|
||||
expect(applyEdit(sqlServerOutputSql, sqlServerOutputSql.indexOf(' FROM'), 'updated_at'))
|
||||
.toBe('UPDATE users SET active = 1 OUTPUT updated_at FROM users WHERE users.id = 1');
|
||||
|
||||
const sqlServerFromSql = 'UPDATE users SET active = 1 FROM users WHERE users.id = 1';
|
||||
expect(applyEdit(sqlServerFromSql, sqlServerFromSql.indexOf(' WHERE'), 'audit'))
|
||||
.toBe('UPDATE users SET active = 1 FROM users audit WHERE users.id = 1');
|
||||
});
|
||||
|
||||
it('does not add a comma in predicate contexts', () => {
|
||||
const sql = 'delete from users where = 1';
|
||||
expect(applyEdit(sql, sql.indexOf('=') - 1, 'id')).toBe('delete from users where id = 1');
|
||||
});
|
||||
|
||||
it('snaps a drop inside an identifier to the complete field boundary', () => {
|
||||
const sql = 'SELECT announcement_id FROM announcements';
|
||||
const rawOffset = sql.indexOf('announcement_id') + 2;
|
||||
expect(resolveSqlFieldDropCursorOffset(sql, rawOffset)).toBe(sql.indexOf('announcement_id') + 'announcement_id'.length);
|
||||
expect(applyEdit(sql, rawOffset, 'org_id')).toBe('SELECT announcement_id, org_id FROM announcements');
|
||||
});
|
||||
|
||||
it('adds the trailing comma when inserting before the first field', () => {
|
||||
const sql = 'SELECT announcement_id, created_at FROM announcements';
|
||||
expect(applyEdit(sql, sql.indexOf('announcement_id'), 'org_id'))
|
||||
.toBe('SELECT org_id, announcement_id, created_at FROM announcements');
|
||||
});
|
||||
|
||||
it('rejects duplicate fields including aliases and qualified references', () => {
|
||||
const sql = 'SELECT a.announcement_id an, org_id org_i FROM announcements a';
|
||||
expect(applyEdit(sql, sql.indexOf('org_id'), 'announcement_id')).toBe(sql);
|
||||
expect(applyEdit(sql, sql.indexOf(' FROM'), 'org_id')).toBe(sql);
|
||||
expect(applyEdit(sql, sql.indexOf(' FROM'), 'an')).toBe(sql);
|
||||
});
|
||||
|
||||
it('treats function calls with commas as one projection item', () => {
|
||||
const sql = 'SELECT COALESCE(title, short_title) display_title, created_at FROM announcements';
|
||||
expect(applyEdit(sql, sql.indexOf('short_title') + 3, 'org_id'))
|
||||
.toBe('SELECT COALESCE(title, short_title) display_title, org_id, created_at FROM announcements');
|
||||
});
|
||||
|
||||
it('preserves select modifiers when replacing a star', () => {
|
||||
const sql = 'SELECT DISTINCT * FROM announcements';
|
||||
expect(applyEdit(sql, sql.indexOf('*'), 'announcement_id'))
|
||||
.toBe('SELECT DISTINCT announcement_id FROM announcements');
|
||||
});
|
||||
|
||||
it('only treats direct fields and output aliases as duplicates', () => {
|
||||
const sql = 'SELECT COUNT(announcement_id) announcement_count FROM announcements';
|
||||
expect(applyEdit(sql, sql.indexOf(' FROM'), 'announcement_id'))
|
||||
.toBe('SELECT COUNT(announcement_id) announcement_count, announcement_id FROM announcements');
|
||||
expect(applyEdit(sql, sql.indexOf(' FROM'), 'announcement_count')).toBe(sql);
|
||||
});
|
||||
|
||||
it('keeps dialect-specific select modifiers ahead of inserted fields', () => {
|
||||
const topSql = 'SELECT TOP 10 announcement_id FROM announcements';
|
||||
expect(applyEdit(topSql, topSql.indexOf('announcement_id'), 'org_id'))
|
||||
.toBe('SELECT TOP 10 org_id, announcement_id FROM announcements');
|
||||
|
||||
const distinctOnSql = 'SELECT DISTINCT ON (org_id) announcement_id FROM announcements';
|
||||
expect(applyEdit(distinctOnSql, distinctOnSql.indexOf('announcement_id'), 'created_at'))
|
||||
.toBe('SELECT DISTINCT ON (org_id) created_at, announcement_id FROM announcements');
|
||||
|
||||
const mysqlSql = 'SELECT SQL_CALC_FOUND_ROWS announcement_id FROM announcements';
|
||||
expect(applyEdit(mysqlSql, mysqlSql.indexOf('announcement_id'), 'org_id'))
|
||||
.toBe('SELECT SQL_CALC_FOUND_ROWS org_id, announcement_id FROM announcements');
|
||||
});
|
||||
|
||||
it('anchors and inserts after the field below the horizontal drag position', () => {
|
||||
const sql = 'SELECT org_id, title FROM a_cninfo_announcement';
|
||||
const orgOffset = sql.indexOf('org_id') + 2;
|
||||
const titleOffset = sql.indexOf('title') + 2;
|
||||
|
||||
expect(resolveSqlFieldDropAnchorRange(sql, resolveSqlFieldDropCursorOffset(sql, orgOffset))).toEqual({
|
||||
startOffset: sql.indexOf('org_id'),
|
||||
endOffset: sql.indexOf('org_id') + 'org_id'.length,
|
||||
});
|
||||
expect(applyEdit(sql, resolveSqlFieldDropCursorOffset(sql, orgOffset), 'announcement_id'))
|
||||
.toBe('SELECT org_id, announcement_id, title FROM a_cninfo_announcement');
|
||||
|
||||
expect(resolveSqlFieldDropAnchorRange(sql, resolveSqlFieldDropCursorOffset(sql, titleOffset))).toEqual({
|
||||
startOffset: sql.indexOf('title'),
|
||||
endOffset: sql.indexOf('title') + 'title'.length,
|
||||
});
|
||||
expect(applyEdit(sql, resolveSqlFieldDropCursorOffset(sql, titleOffset), 'announcement_id'))
|
||||
.toBe('SELECT org_id, title, announcement_id FROM a_cninfo_announcement');
|
||||
});
|
||||
|
||||
it('uses the nearest field on either side of projection whitespace', () => {
|
||||
const sql = 'SELECT org_id, title FROM a_cninfo_announcement';
|
||||
const gapStart = sql.indexOf('org_id') + 'org_id'.length;
|
||||
const nearOrg = gapStart + 1;
|
||||
const nearTitle = sql.indexOf('title') - 1;
|
||||
|
||||
expect(resolveSqlFieldDropAnchorRange(sql, nearOrg)).toEqual({
|
||||
startOffset: sql.indexOf('org_id'),
|
||||
endOffset: gapStart,
|
||||
});
|
||||
expect(resolveSqlFieldDropAnchorRange(sql, nearTitle)).toEqual({
|
||||
startOffset: sql.indexOf('title'),
|
||||
endOffset: sql.indexOf('title') + 'title'.length,
|
||||
});
|
||||
expect(applyEdit(sql, nearTitle, 'announcement_id'))
|
||||
.toBe('SELECT org_id, title, announcement_id FROM a_cninfo_announcement');
|
||||
});
|
||||
});
|
||||
474
frontend/src/utils/sqlFieldDrop.ts
Normal file
474
frontend/src/utils/sqlFieldDrop.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
export interface SqlFieldDropEditInput {
|
||||
sql: string;
|
||||
offset: number;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export interface SqlFieldDropEdit {
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SqlFieldDropAnchorRange {
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
}
|
||||
|
||||
export const SQL_FIELD_DRAG_MIME = 'application/x-gonavi-sql-field';
|
||||
|
||||
export const hasSqlFieldDragPayload = (
|
||||
dataTransfer: Pick<DataTransfer, 'types'> | null | undefined,
|
||||
): boolean => Array.from(dataTransfer?.types || [])
|
||||
.some((type) => String(type || '').toLowerCase() === SQL_FIELD_DRAG_MIME);
|
||||
|
||||
type SqlToken = {
|
||||
kind: 'word' | 'identifier' | 'string' | 'symbol';
|
||||
value: string;
|
||||
start: number;
|
||||
end: number;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
type SqlProjection = {
|
||||
selectToken: SqlToken;
|
||||
contentStart: number;
|
||||
contentEnd: number;
|
||||
items: Array<{ start: number; end: number; text: string }>;
|
||||
commaOffsets: number[];
|
||||
};
|
||||
|
||||
const isWordStart = (char: string): boolean => !!char && (/[A-Za-z_@$#]/.test(char) || char.charCodeAt(0) > 127);
|
||||
const isWordPart = (char: string): boolean => !!char && (/[A-Za-z0-9_@$#]/.test(char) || char.charCodeAt(0) > 127);
|
||||
|
||||
const tokenizeSql = (sql: string): SqlToken[] => {
|
||||
const tokens: SqlToken[] = [];
|
||||
let index = 0;
|
||||
let depth = 0;
|
||||
|
||||
while (index < sql.length) {
|
||||
const char = sql[index];
|
||||
if (/\s/.test(char)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '-' && sql[index + 1] === '-') {
|
||||
const lineEnd = sql.indexOf('\n', index + 2);
|
||||
index = lineEnd < 0 ? sql.length : lineEnd + 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '/' && sql[index + 1] === '*') {
|
||||
const commentEnd = sql.indexOf('*/', index + 2);
|
||||
index = commentEnd < 0 ? sql.length : commentEnd + 2;
|
||||
continue;
|
||||
}
|
||||
if (char === "'") {
|
||||
const start = index;
|
||||
index += 1;
|
||||
while (index < sql.length) {
|
||||
if (sql[index] === "'" && sql[index + 1] === "'") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql[index] === "'") {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
tokens.push({ kind: 'string', value: sql.slice(start, index), start, end: index, depth });
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === '`' || char === '[') {
|
||||
const start = index;
|
||||
const closing = char === '[' ? ']' : char;
|
||||
index += 1;
|
||||
while (index < sql.length) {
|
||||
if (sql[index] === closing && sql[index + 1] === closing) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql[index] === closing) {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
tokens.push({
|
||||
kind: 'identifier',
|
||||
value: sql.slice(start + 1, Math.max(start + 1, index - 1)),
|
||||
start,
|
||||
end: index,
|
||||
depth,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isWordStart(char)) {
|
||||
const start = index;
|
||||
index += 1;
|
||||
while (index < sql.length && isWordPart(sql[index])) index += 1;
|
||||
tokens.push({ kind: 'word', value: sql.slice(start, index), start, end: index, depth });
|
||||
continue;
|
||||
}
|
||||
if (char === '(') {
|
||||
tokens.push({ kind: 'symbol', value: char, start: index, end: index + 1, depth });
|
||||
depth += 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ')') {
|
||||
depth = Math.max(0, depth - 1);
|
||||
tokens.push({ kind: 'symbol', value: char, start: index, end: index + 1, depth });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
tokens.push({ kind: 'symbol', value: char, start: index, end: index + 1, depth });
|
||||
index += 1;
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const trimRange = (sql: string, start: number, end: number): { start: number; end: number; text: string } | null => {
|
||||
let nextStart = start;
|
||||
let nextEnd = end;
|
||||
while (nextStart < nextEnd && /\s/.test(sql[nextStart])) nextStart += 1;
|
||||
while (nextEnd > nextStart && /\s/.test(sql[nextEnd - 1])) nextEnd -= 1;
|
||||
return nextStart < nextEnd
|
||||
? { start: nextStart, end: nextEnd, text: sql.slice(nextStart, nextEnd) }
|
||||
: null;
|
||||
};
|
||||
|
||||
const findStatementBounds = (tokens: SqlToken[], offset: number, sqlLength: number): { start: number; end: number } => {
|
||||
let start = 0;
|
||||
let end = sqlLength;
|
||||
tokens.forEach((token) => {
|
||||
if (token.kind !== 'symbol' || token.value !== ';' || token.depth !== 0) return;
|
||||
if (token.end <= offset) start = token.end;
|
||||
else if (token.start >= offset && end === sqlLength) end = token.start;
|
||||
});
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const findSelectProjection = (sql: string, offset: number, tokens: SqlToken[]): SqlProjection | null => {
|
||||
const statement = findStatementBounds(tokens, offset, sql.length);
|
||||
const selectTokens = tokens.filter((token) => (
|
||||
token.kind === 'word'
|
||||
&& token.value.toLowerCase() === 'select'
|
||||
&& token.start >= statement.start
|
||||
&& token.end <= offset
|
||||
));
|
||||
|
||||
for (let selectIndex = selectTokens.length - 1; selectIndex >= 0; selectIndex -= 1) {
|
||||
const selectToken = selectTokens[selectIndex];
|
||||
const fromToken = tokens.find((token) => (
|
||||
token.kind === 'word'
|
||||
&& token.value.toLowerCase() === 'from'
|
||||
&& token.depth === selectToken.depth
|
||||
&& token.start >= selectToken.end
|
||||
&& token.start < statement.end
|
||||
));
|
||||
const contentEnd = fromToken?.start ?? statement.end;
|
||||
if (offset < selectToken.end || offset > contentEnd) continue;
|
||||
|
||||
let contentStart = selectToken.end;
|
||||
const modifierMatch = sql.slice(contentStart, contentEnd).match(
|
||||
/^\s*(?:(?:distinct\s+on\s*\([^)]*\)|distinct\b|all\b)\s*)?(?:top\s*(?:\([^)]*\)|\d+(?:\.\d+)?)\s*(?:percent\s*)?(?:with\s+ties\s*)?)?(?:(?:high_priority|straight_join|sql_small_result|sql_big_result|sql_buffer_result|sql_no_cache|sql_calc_found_rows)\b\s*)*/i,
|
||||
);
|
||||
if (modifierMatch?.[0] && /\S/.test(modifierMatch[0])) contentStart += modifierMatch[0].length;
|
||||
|
||||
const commaOffsets = tokens
|
||||
.filter((token) => token.kind === 'symbol'
|
||||
&& token.value === ','
|
||||
&& token.depth === selectToken.depth
|
||||
&& token.start >= contentStart
|
||||
&& token.start < contentEnd)
|
||||
.map((token) => token.start);
|
||||
const boundaries = [contentStart, ...commaOffsets.map((comma) => comma + 1), contentEnd];
|
||||
const segmentEnds = [...commaOffsets, contentEnd];
|
||||
const items = boundaries
|
||||
.slice(0, segmentEnds.length)
|
||||
.map((start, index) => trimRange(sql, start, segmentEnds[index]))
|
||||
.filter((item): item is NonNullable<typeof item> => !!item);
|
||||
return { selectToken, contentStart, contentEnd, items, commaOffsets };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const findInsertColumnList = (sql: string, offset: number, tokens: SqlToken[]): SqlProjection | null => {
|
||||
const statement = findStatementBounds(tokens, offset, sql.length);
|
||||
const insertToken = tokens.find((token) => token.kind === 'word'
|
||||
&& token.value.toLowerCase() === 'insert'
|
||||
&& token.start >= statement.start
|
||||
&& token.end <= offset);
|
||||
if (!insertToken) return null;
|
||||
const intoToken = tokens.find((token) => token.kind === 'word'
|
||||
&& token.value.toLowerCase() === 'into'
|
||||
&& token.depth === insertToken.depth
|
||||
&& token.start >= insertToken.end
|
||||
&& token.end <= offset);
|
||||
if (!intoToken) return null;
|
||||
const valuesToken = tokens.find((token) => token.kind === 'word'
|
||||
&& token.value.toLowerCase() === 'values'
|
||||
&& token.depth === insertToken.depth
|
||||
&& token.start >= intoToken.end
|
||||
&& token.start < statement.end);
|
||||
const openToken = tokens.find((token) => token.kind === 'symbol'
|
||||
&& token.value === '('
|
||||
&& token.depth === insertToken.depth
|
||||
&& token.start >= intoToken.end
|
||||
&& token.start < (valuesToken?.start ?? statement.end));
|
||||
if (!openToken) return null;
|
||||
const closeToken = tokens.find((token) => token.kind === 'symbol'
|
||||
&& token.value === ')'
|
||||
&& token.depth === openToken.depth
|
||||
&& token.start >= openToken.end
|
||||
&& token.start < (valuesToken?.start ?? statement.end));
|
||||
if (!closeToken || offset < openToken.end || offset > closeToken.start) return null;
|
||||
|
||||
const contentStart = openToken.end;
|
||||
const contentEnd = closeToken.start;
|
||||
const commaOffsets = tokens
|
||||
.filter((token) => token.kind === 'symbol'
|
||||
&& token.value === ','
|
||||
&& token.depth === openToken.depth + 1
|
||||
&& token.start >= contentStart
|
||||
&& token.start < contentEnd)
|
||||
.map((token) => token.start);
|
||||
const boundaries = [contentStart, ...commaOffsets.map((comma) => comma + 1), contentEnd];
|
||||
const segmentEnds = [...commaOffsets, contentEnd];
|
||||
const items = boundaries
|
||||
.slice(0, segmentEnds.length)
|
||||
.map((start, index) => trimRange(sql, start, segmentEnds[index]))
|
||||
.filter((item): item is NonNullable<typeof item> => !!item);
|
||||
return { selectToken: insertToken, contentStart, contentEnd, items, commaOffsets };
|
||||
};
|
||||
|
||||
const normalizeIdentifier = (value: string): string => {
|
||||
const text = String(value || '').trim();
|
||||
const unquoted = (text.startsWith('`') && text.endsWith('`'))
|
||||
|| (text.startsWith('"') && text.endsWith('"'))
|
||||
|| (text.startsWith('[') && text.endsWith(']'))
|
||||
? text.slice(1, -1)
|
||||
: text;
|
||||
const parts = unquoted.split('.').map((part) => part.trim()).filter(Boolean);
|
||||
return String(parts[parts.length - 1] || '').toLowerCase();
|
||||
};
|
||||
|
||||
const projectionContainsField = (projection: SqlProjection, fieldName: string): boolean => {
|
||||
const target = normalizeIdentifier(fieldName);
|
||||
if (!target) return false;
|
||||
return projection.items.some((item) => {
|
||||
const tokens = tokenizeSql(item.text);
|
||||
const identifierTokens = tokens.filter((token) => token.kind === 'word' || token.kind === 'identifier');
|
||||
const topLevelIdentifiers = identifierTokens.filter((token) => token.depth === 0);
|
||||
const asIndex = topLevelIdentifiers.findIndex((token) => token.value.toLowerCase() === 'as');
|
||||
const explicitAlias = asIndex >= 0 ? topLevelIdentifiers[asIndex + 1] : undefined;
|
||||
if (explicitAlias && normalizeIdentifier(explicitAlias.value) === target) return true;
|
||||
|
||||
const hasTopLevelExpressionSymbol = tokens.some((token) => token.kind === 'symbol'
|
||||
&& token.depth === 0
|
||||
&& token.value !== '.');
|
||||
if (hasTopLevelExpressionSymbol) {
|
||||
const lastToken = tokens[tokens.length - 1];
|
||||
return !!lastToken
|
||||
&& (lastToken.kind === 'word' || lastToken.kind === 'identifier')
|
||||
&& lastToken !== topLevelIdentifiers[0]
|
||||
&& normalizeIdentifier(lastToken.value) === target;
|
||||
}
|
||||
|
||||
const dotCount = tokens.filter((token) => token.kind === 'symbol' && token.depth === 0 && token.value === '.').length;
|
||||
const nonAsIdentifiers = topLevelIdentifiers.filter((token) => token.value.toLowerCase() !== 'as');
|
||||
const sourceIdentifierCount = Math.min(nonAsIdentifiers.length, dotCount + 1);
|
||||
const sourceIdentifier = nonAsIdentifiers[sourceIdentifierCount - 1];
|
||||
const implicitAlias = nonAsIdentifiers[sourceIdentifierCount];
|
||||
return normalizeIdentifier(sourceIdentifier?.value || '') === target
|
||||
|| normalizeIdentifier(implicitAlias?.value || '') === target;
|
||||
});
|
||||
};
|
||||
|
||||
type SqlProjectionDropPlacement = {
|
||||
item: SqlProjection['items'][number];
|
||||
position: 'before' | 'after';
|
||||
};
|
||||
|
||||
const resolveProjectionDropPlacement = (
|
||||
projection: SqlProjection,
|
||||
rawOffset: number,
|
||||
): SqlProjectionDropPlacement | null => {
|
||||
const firstItem = projection.items[0];
|
||||
if (!firstItem) return null;
|
||||
if (rawOffset <= firstItem.start) {
|
||||
return { item: firstItem, position: 'before' };
|
||||
}
|
||||
|
||||
for (let index = 0; index < projection.items.length; index += 1) {
|
||||
const item = projection.items[index];
|
||||
const nextItem = projection.items[index + 1];
|
||||
if (rawOffset <= item.end) {
|
||||
return { item, position: 'after' };
|
||||
}
|
||||
if (!nextItem) {
|
||||
return { item, position: 'after' };
|
||||
}
|
||||
if (rawOffset < nextItem.start) {
|
||||
const distanceFromPrevious = Math.abs(rawOffset - item.end);
|
||||
const distanceToNext = Math.abs(nextItem.start - rawOffset);
|
||||
return distanceFromPrevious <= distanceToNext
|
||||
? { item, position: 'after' }
|
||||
: { item: nextItem, position: 'after' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 将落在完整标识符内部的位置吸附到标识符末尾,避免拆词。 */
|
||||
export const resolveSqlFieldDropCursorOffset = (sql: string, offset: number): number => {
|
||||
const source = String(sql || '');
|
||||
const cursor = Math.max(0, Math.min(Number.isFinite(offset) ? offset : 0, source.length));
|
||||
const tokens = tokenizeSql(source);
|
||||
const projection = findSelectProjection(source, cursor, tokens)
|
||||
|| findInsertColumnList(source, cursor, tokens);
|
||||
const projectionItem = projection?.items.find((item) => cursor > item.start && cursor < item.end);
|
||||
if (projectionItem) return projectionItem.end;
|
||||
const token = tokens.find((candidate) => (
|
||||
(candidate.kind === 'word' || candidate.kind === 'identifier')
|
||||
&& cursor > candidate.start
|
||||
&& cursor < candidate.end
|
||||
));
|
||||
return token?.end ?? cursor;
|
||||
};
|
||||
|
||||
/** 返回拖拽释放后作为插入基准的完整字段或表达式范围,用于编辑器预览高亮。 */
|
||||
export const resolveSqlFieldDropAnchorRange = (
|
||||
sql: string,
|
||||
offset: number,
|
||||
): SqlFieldDropAnchorRange | null => {
|
||||
const source = String(sql || '');
|
||||
const rawOffset = Math.max(0, Math.min(Number.isFinite(offset) ? offset : 0, source.length));
|
||||
const tokens = tokenizeSql(source);
|
||||
const projection = findSelectProjection(source, rawOffset, tokens)
|
||||
|| findInsertColumnList(source, rawOffset, tokens);
|
||||
const placement = projection ? resolveProjectionDropPlacement(projection, rawOffset) : null;
|
||||
if (!placement || placement.position !== 'after') return null;
|
||||
return {
|
||||
startOffset: placement.item.start,
|
||||
endOffset: placement.item.end,
|
||||
};
|
||||
};
|
||||
|
||||
const buildProjectionEdit = (
|
||||
sql: string,
|
||||
projection: SqlProjection,
|
||||
rawOffset: number,
|
||||
fieldName: string,
|
||||
): SqlFieldDropEdit | null => {
|
||||
if (projectionContainsField(projection, fieldName)) return null;
|
||||
if (projection.items.length === 1 && projection.items[0].text.trim() === '*') {
|
||||
return {
|
||||
startOffset: projection.items[0].start,
|
||||
endOffset: projection.items[0].end,
|
||||
text: fieldName,
|
||||
};
|
||||
}
|
||||
if (projection.items.length === 0) {
|
||||
const isSelectProjection = projection.selectToken.value.toLowerCase() === 'select';
|
||||
return {
|
||||
startOffset: projection.contentStart,
|
||||
endOffset: projection.contentEnd,
|
||||
text: isSelectProjection ? ` ${fieldName} ` : fieldName,
|
||||
};
|
||||
}
|
||||
|
||||
const trailingComma = projection.commaOffsets.find((comma) => comma >= projection.items[projection.items.length - 1].end);
|
||||
if (trailingComma !== undefined && rawOffset >= trailingComma) {
|
||||
return {
|
||||
startOffset: trailingComma + 1,
|
||||
endOffset: projection.contentEnd,
|
||||
text: ` ${fieldName} `,
|
||||
};
|
||||
}
|
||||
|
||||
const placement = resolveProjectionDropPlacement(projection, rawOffset);
|
||||
if (!placement) return null;
|
||||
if (placement.position === 'before') {
|
||||
return { startOffset: placement.item.start, endOffset: placement.item.start, text: `${fieldName}, ` };
|
||||
}
|
||||
const isLastItem = placement.item === projection.items[projection.items.length - 1];
|
||||
return isLastItem
|
||||
? {
|
||||
startOffset: placement.item.end,
|
||||
endOffset: projection.contentEnd,
|
||||
text: `, ${fieldName} `,
|
||||
}
|
||||
: {
|
||||
startOffset: placement.item.end,
|
||||
endOffset: placement.item.end,
|
||||
text: `, ${fieldName}`,
|
||||
};
|
||||
};
|
||||
|
||||
const buildUpdateSetEdit = (
|
||||
sql: string,
|
||||
tokens: SqlToken[],
|
||||
rawOffset: number,
|
||||
fieldName: string,
|
||||
): SqlFieldDropEdit | null => {
|
||||
const statement = findStatementBounds(tokens, rawOffset, sql.length);
|
||||
const updateToken = tokens.find((token) => token.kind === 'word'
|
||||
&& token.value.toLowerCase() === 'update'
|
||||
&& token.start >= statement.start
|
||||
&& token.end <= rawOffset);
|
||||
if (!updateToken) return null;
|
||||
const setToken = tokens.find((token) => token.kind === 'word'
|
||||
&& token.value.toLowerCase() === 'set'
|
||||
&& token.depth === updateToken.depth
|
||||
&& token.start >= updateToken.end
|
||||
&& token.end <= rawOffset);
|
||||
if (!setToken) return null;
|
||||
const clauseEndToken = tokens.find((token) => token.kind === 'word'
|
||||
&& ['from', 'output', 'where', 'returning', 'order', 'limit'].includes(token.value.toLowerCase())
|
||||
&& token.depth === updateToken.depth
|
||||
&& token.start >= setToken.end);
|
||||
const clauseEnd = clauseEndToken?.start ?? statement.end;
|
||||
if (rawOffset > clauseEnd) return null;
|
||||
const existingText = sql.slice(setToken.end, clauseEnd);
|
||||
const existingIdentifiers = tokenizeSql(existingText)
|
||||
.filter((token) => token.kind === 'word' || token.kind === 'identifier')
|
||||
.map((token) => normalizeIdentifier(token.value));
|
||||
if (existingIdentifiers.includes(normalizeIdentifier(fieldName))) return null;
|
||||
const trimmed = trimRange(sql, setToken.end, clauseEnd);
|
||||
if (!trimmed) {
|
||||
return { startOffset: setToken.end, endOffset: clauseEnd, text: ` ${fieldName} ` };
|
||||
}
|
||||
return { startOffset: trimmed.end, endOffset: clauseEnd, text: `, ${fieldName} ` };
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算结果集字段拖入 SQL 编辑器时的最小编辑范围。
|
||||
* SELECT 字段列表按完整表达式插入,并拒绝已有字段;其它位置只做安全标记边界插入。
|
||||
*/
|
||||
export const buildSqlFieldDropEdit = ({ sql, offset, fieldName }: SqlFieldDropEditInput): SqlFieldDropEdit | null => {
|
||||
const source = String(sql || '');
|
||||
const field = String(fieldName || '').trim();
|
||||
const rawOffset = Math.max(0, Math.min(Number.isFinite(offset) ? offset : 0, source.length));
|
||||
if (!field) return null;
|
||||
|
||||
const tokens = tokenizeSql(source);
|
||||
const projection = findSelectProjection(source, rawOffset, tokens);
|
||||
if (projection) return buildProjectionEdit(source, projection, rawOffset, field);
|
||||
const insertColumnList = findInsertColumnList(source, rawOffset, tokens);
|
||||
if (insertColumnList) return buildProjectionEdit(source, insertColumnList, rawOffset, field);
|
||||
const updateSetEdit = buildUpdateSetEdit(source, tokens, rawOffset, field);
|
||||
if (updateSetEdit) return updateSetEdit;
|
||||
|
||||
const cursor = resolveSqlFieldDropCursorOffset(source, rawOffset);
|
||||
const before = source.slice(0, cursor);
|
||||
const after = source.slice(cursor);
|
||||
const needsLeadingSpace = !!before && !/[\s(,]$/.test(before);
|
||||
const needsTrailingSpace = !!after && !/^[\s),;]/.test(after);
|
||||
return {
|
||||
startOffset: cursor,
|
||||
endOffset: cursor,
|
||||
text: `${needsLeadingSpace ? ' ' : ''}${field}${needsTrailingSpace ? ' ' : ''}`,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user