From f0ffef2080d1879c87133b14d17091ea5b7db4fa Mon Sep 17 00:00:00 2001 From: kunghim Date: Thu, 6 Aug 2026 18:13:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat(query):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=8B=96=E6=8B=BD=E7=BB=93=E6=9E=9C=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=8F=92=E5=85=A5=20SQL=20(#848)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 9 + frontend/src/components/DataGridCore.tsx | 21 +- .../QueryEditor.results-and-drop.test.tsx | 83 +++ frontend/src/components/QueryEditor.tsx | 176 ++++++- frontend/src/utils/sqlFieldDrop.test.ts | 149 ++++++ frontend/src/utils/sqlFieldDrop.ts | 474 ++++++++++++++++++ 6 files changed, 908 insertions(+), 4 deletions(-) create mode 100644 frontend/src/utils/sqlFieldDrop.test.ts create mode 100644 frontend/src/utils/sqlFieldDrop.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index cb6bfe7c..9b348081 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; } diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx index c7cdd477..b0b688ed 100644 --- a/frontend/src/components/DataGridCore.tsx +++ b/frontend/src/components/DataGridCore.tsx @@ -75,6 +75,8 @@ 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 { TEMPORAL_FORMATS, formatFromDayjs, @@ -877,7 +879,24 @@ const SortableHeaderCell: React.FC = React.memo((props) }} > -
+
{ + const columnName = String(id || '').trim(); + if (!columnName || !event.dataTransfer) return; + event.stopPropagation(); + event.dataTransfer.effectAllowed = 'copy'; + const payload = encodeSidebarSqlEditorDragPayload({ + text: columnName, + nodeType: 'column', + }); + event.dataTransfer.setData(SIDEBAR_SQL_EDITOR_DRAG_MIME, payload); + event.dataTransfer.setData(SQL_FIELD_DRAG_MIME, columnName); + event.dataTransfer.setData('text/plain', columnName); + }} + >
{children}
diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index da3a06f8..202d2118 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -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 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(); + }); + + 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({ diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index ef822b77..3f850c4a 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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([]); const ctrlMetaPressedRef = useRef(false); const objectDecorationIdsRef = useRef([]); + const sqlFieldDropDecorationIdsRef = useRef([]); const aiInlineGhostDecorationIdsRef = useRef([]); const aiInlineGhostOverlayRef = useRef(null); const aiInlineGhostVisibleContextKeyRef = useRef(null); @@ -2995,6 +3002,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) => { if (!payload?.text || !payload.dbName) { return; @@ -3042,12 +3156,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; @@ -5109,6 +5253,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); @@ -5348,10 +5511,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) => { @@ -5523,6 +5689,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; @@ -5545,11 +5712,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); }); diff --git a/frontend/src/utils/sqlFieldDrop.test.ts b/frontend/src/utils/sqlFieldDrop.test.ts new file mode 100644 index 00000000..34d00284 --- /dev/null +++ b/frontend/src/utils/sqlFieldDrop.test.ts @@ -0,0 +1,149 @@ +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 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'); + }); +}); diff --git a/frontend/src/utils/sqlFieldDrop.ts b/frontend/src/utils/sqlFieldDrop.ts new file mode 100644 index 00000000..cd4ea229 --- /dev/null +++ b/frontend/src/utils/sqlFieldDrop.ts @@ -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 | 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 => !!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 => !!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' + && ['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 ? ' ' : ''}`, + }; +}; From 7cb276cc9fa0c520658e021810a08c5f3a0b69d1 Mon Sep 17 00:00:00 2001 From: kunghim Date: Fri, 7 Aug 2026 15:21:22 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E8=A1=A8=E5=A4=B4=E6=8E=92=E5=BA=8F=E4=B8=8E=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=8B=96=E5=85=A5=E6=9F=A5=E8=AF=A2=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/DataGrid.tsx | 75 +++++++++++-------- frontend/src/components/DataGridCore.tsx | 25 ++++++- .../components/dataGridColumnOrder.test.ts | 43 +++++++++++ .../src/components/dataGridColumnOrder.ts | 57 ++++++++++++++ 4 files changed, 165 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/dataGridColumnOrder.test.ts create mode 100644 frontend/src/components/dataGridColumnOrder.ts diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index fbfa3a3e..6ed3b7af 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -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 = ({ 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)); } }; @@ -3110,11 +3102,28 @@ const DataGrid: React.FC = ({ }, 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) => { + if (!hasDataGridColumnOrderDragPayload(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + }, + onDrop: (event: React.DragEvent) => { + 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) => { if (!isV2Ui) return; showColumnHeaderContextMenu(event, key); @@ -3155,7 +3164,7 @@ const DataGrid: React.FC = ({ }, }), })); - }, [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 => { const dataIndex = String(col.dataIndex); diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx index b0b688ed..79a74210 100644 --- a/frontend/src/components/DataGridCore.tsx +++ b/frontend/src/components/DataGridCore.tsx @@ -77,6 +77,11 @@ import { DEFAULT_SHORTCUT_OPTIONS, getShortcutPlatform, resolveShortcutDisplay } 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, @@ -784,6 +789,7 @@ const ResizableTitle = React.forwardRef((props, ref) // --- Sortable Header Cell --- interface SortableHeaderCellProps extends React.HTMLAttributes { id?: string; + columnOrderDragScope?: string; } // --- Sortable Header Cell --- @@ -817,7 +823,7 @@ const sortableHeaderStaticStyles = ` `; const SortableHeaderCell: React.FC = 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, @@ -875,6 +881,12 @@ const SortableHeaderCell: React.FC = 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); }} > @@ -887,13 +899,22 @@ const SortableHeaderCell: React.FC = React.memo((props) const columnName = String(id || '').trim(); if (!columnName || !event.dataTransfer) return; event.stopPropagation(); - event.dataTransfer.effectAllowed = 'copy'; + 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); }} > diff --git a/frontend/src/components/dataGridColumnOrder.test.ts b/frontend/src/components/dataGridColumnOrder.test.ts new file mode 100644 index 00000000..d3f241cb --- /dev/null +++ b/frontend/src/components/dataGridColumnOrder.test.ts @@ -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); + }); +}); diff --git a/frontend/src/components/dataGridColumnOrder.ts b/frontend/src/components/dataGridColumnOrder.ts new file mode 100644 index 00000000..ec97f0a8 --- /dev/null +++ b/frontend/src/components/dataGridColumnOrder.ts @@ -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 | 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, + 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++] + )); +}; From 6de2304531f14fa02458933fe68347655003ea88 Mon Sep 17 00:00:00 2001 From: mango <1711456624@qq.com> Date: Fri, 7 Aug 2026 21:15:59 +0800 Subject: [PATCH 3/3] fix(data-grid): handle update clause boundaries --- frontend/src/utils/sqlFieldDrop.test.ts | 14 ++++++++++++++ frontend/src/utils/sqlFieldDrop.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/utils/sqlFieldDrop.test.ts b/frontend/src/utils/sqlFieldDrop.test.ts index 34d00284..451f1964 100644 --- a/frontend/src/utils/sqlFieldDrop.test.ts +++ b/frontend/src/utils/sqlFieldDrop.test.ts @@ -51,6 +51,20 @@ describe('buildSqlFieldDropEdit', () => { .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'); diff --git a/frontend/src/utils/sqlFieldDrop.ts b/frontend/src/utils/sqlFieldDrop.ts index cd4ea229..d863f5c1 100644 --- a/frontend/src/utils/sqlFieldDrop.ts +++ b/frontend/src/utils/sqlFieldDrop.ts @@ -426,7 +426,7 @@ const buildUpdateSetEdit = ( && token.end <= rawOffset); if (!setToken) return null; const clauseEndToken = tokens.find((token) => token.kind === 'word' - && ['where', 'returning', 'order', 'limit'].includes(token.value.toLowerCase()) + && ['from', 'output', 'where', 'returning', 'order', 'limit'].includes(token.value.toLowerCase()) && token.depth === updateToken.depth && token.start >= setToken.end); const clauseEnd = clauseEndToken?.start ?? statement.end;