diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7b92684..f9f1ce4 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -91,6 +91,26 @@ import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts' import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; import JVMModeBadge from './jvm/JVMModeBadge'; +import { + SEARCH_SCOPE_LABEL_MAP, + SEARCH_SCOPE_OPTIONS, + SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, + SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, + buildConnectionReloadSignature, + isConnectionTreeKey, + isExternalSQLDirectoryModalMode, + isPostgresSchemaDialect, + isV2SidebarObjectNode, + normalizeDriverType, + normalizeMySQLViewDDLForEditing, + resolveSavedConnectionDriverType, + resolveSidebarContextMenuPosition, + resolveSidebarObjectDragText, + type ExternalSQLFileModalMode, + type SearchScope, +} from './sidebarCoreUtils'; +export { resolveSidebarContextMenuPosition } from './sidebarCoreUtils'; +export type { ExternalSQLFileModalMode, SearchScope } from './sidebarCoreUtils'; import { V2DatabaseContextMenuView, V2ConnectionGroupContextMenuView, @@ -190,42 +210,9 @@ type SidebarContextMenuState = { maxHeight?: number; }; -const SIDEBAR_CONTEXT_MENU_SAFE_GAP = 8; -const SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH = 264; -const SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT = 420; const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50; const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160; -type ExternalSQLFileModalMode = 'create' | 'rename' | 'create-directory' | 'rename-directory'; -const isExternalSQLDirectoryModalMode = (mode: ExternalSQLFileModalMode): boolean => - mode === 'create-directory' || mode === 'rename-directory'; - -export const resolveSidebarContextMenuPosition = ( - x: number, - y: number, - options?: { - width?: number; - height?: number; - viewportWidth?: number; - viewportHeight?: number; - safeGap?: number; - }, -): { x: number; y: number; maxHeight: number } => { - const safeGap = options?.safeGap ?? SIDEBAR_CONTEXT_MENU_SAFE_GAP; - const viewportWidth = options?.viewportWidth ?? (typeof window === 'undefined' ? 1024 : window.innerWidth); - const viewportHeight = options?.viewportHeight ?? (typeof window === 'undefined' ? 768 : window.innerHeight); - const width = Math.max(0, options?.width ?? SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH); - const height = Math.max(0, options?.height ?? SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT); - const maxX = Math.max(safeGap, viewportWidth - width - safeGap); - const maxY = Math.max(safeGap, viewportHeight - height - safeGap); - const nextX = Math.max(safeGap, Math.min(x, maxX)); - const nextY = Math.max(safeGap, Math.min(y, maxY)); - return { - x: nextX, - y: nextY, - maxHeight: Math.max(120, viewportHeight - nextY - safeGap), - }; -}; interface TreeNode { title: string; key: string; @@ -237,30 +224,10 @@ interface TreeNode { type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring'; } -const isV2SidebarObjectNode = (node: Pick | null | undefined): boolean => { - return node?.type === 'table' - || node?.type === 'view' - || node?.type === 'materialized-view' - || node?.type === 'db-trigger' - || node?.type === 'db-event' - || node?.type === 'routine'; -}; - -const resolveSidebarObjectDragText = (node: Pick | null | undefined): string => { - const dataRef = node?.dataRef || {}; - if (node?.type === 'table') return String(dataRef.tableName || node?.title || '').trim(); - if (node?.type === 'view' || node?.type === 'materialized-view') return String(dataRef.viewName || dataRef.tableName || node?.title || '').trim(); - if (node?.type === 'db-trigger') return String(dataRef.triggerName || node?.title || '').trim(); - if (node?.type === 'routine') return String(dataRef.routineName || node?.title || '').trim(); - if (node?.type === 'db-event') return String(dataRef.eventName || node?.title || '').trim(); - return ''; -}; - type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; type BatchObjectType = 'table' | 'view'; type BatchObjectFilterType = 'all' | BatchObjectType; type BatchSelectionScope = 'filtered' | 'all'; -type SearchScope = 'smart' | 'object' | 'database' | 'host' | 'tag'; interface BatchObjectItem { title: string; @@ -280,67 +247,8 @@ type DriverStatusSnapshot = { message?: string; }; -const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => { - if (!conn) return ''; - return JSON.stringify({ - config: conn.config || {}, - includeDatabases: conn.includeDatabases || [], - includeRedisDatabases: conn.includeRedisDatabases || [], - }); -}; - -const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => { - const text = String(key); - return text === connectionId || text.startsWith(`${connectionId}-`); -}; - const DRIVER_STATUS_CACHE_TTL_MS = 30_000; -const normalizeDriverType = (value: string): string => { - const normalized = String(value || '').trim().toLowerCase(); - if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres'; - if (normalized === 'elastic') return 'elasticsearch'; - if (normalized === 'doris') return 'diros'; - if ( - normalized === 'open_gauss' || - normalized === 'open-gauss' || - normalized === 'opengauss' - ) return 'opengauss'; - if ( - normalized === 'intersystems' || - normalized === 'intersystemsiris' || - normalized === 'inter-systems' || - normalized === 'inter-systems-iris' - ) return 'iris'; - return normalized; -}; - -const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): string => { - const type = normalizeDriverType(conn?.config?.type || ''); - if (type !== 'custom') { - return type; - } - return normalizeDriverType(conn?.config?.driver || ''); -}; - -const isPostgresSchemaDialect = (dialect: string): boolean => ( - ['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(normalizeDriverType(dialect)) -); - -const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; label: string }> = [ - { value: 'smart', label: '智能' }, - { value: 'object', label: '表对象' }, - { value: 'database', label: '库' }, - { value: 'host', label: 'Host' }, - { value: 'tag', label: '标签' }, -]; - -const SEARCH_SCOPE_LABEL_MAP: Record = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => { - acc[option.value] = option.label; - return acc; -}, {} as Record); - - const SEARCH_SCOPE_ICON_MAP: Record = { smart: , object: , @@ -349,23 +257,6 @@ const SEARCH_SCOPE_ICON_MAP: Record = { tag: , }; -const normalizeMySQLViewDDLForEditing = (viewName: string, rawDefinition: unknown): string => { - const text = String(rawDefinition || '').trim(); - if (!text) return ''; - - const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, ''); - const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i; - if (createViewPrefixPattern.test(normalized)) { - return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`; - } - - if (/^\s*(select|with)\b/i.test(normalized)) { - return `CREATE OR REPLACE VIEW ${viewName} AS\n${normalized};`; - } - - return `${normalized};`; -}; - const Sidebar: React.FC<{ onCreateConnection?: () => void; onEditConnection?: (conn: SavedConnection) => void; diff --git a/frontend/src/components/sidebarCoreUtils.test.ts b/frontend/src/components/sidebarCoreUtils.test.ts new file mode 100644 index 0000000..8bb4f13 --- /dev/null +++ b/frontend/src/components/sidebarCoreUtils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + isPostgresSchemaDialect, + normalizeDriverType, + normalizeMySQLViewDDLForEditing, + resolveSidebarContextMenuPosition, + resolveSidebarObjectDragText, +} from './sidebarCoreUtils'; + +describe('sidebarCoreUtils', () => { + it('keeps context menus inside the viewport', () => { + expect(resolveSidebarContextMenuPosition(790, 590, { + viewportWidth: 800, + viewportHeight: 600, + width: 240, + height: 300, + safeGap: 10, + })).toEqual({ + x: 550, + y: 290, + maxHeight: 300, + }); + }); + + it('normalizes MySQL view definitions for editor updates', () => { + expect(normalizeMySQLViewDDLForEditing('v_users', 'select * from users')).toBe( + 'CREATE OR REPLACE VIEW v_users AS\nselect * from users;', + ); + expect(normalizeMySQLViewDDLForEditing( + 'v_users', + 'CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `v_users` AS select 1;', + )).toBe('CREATE OR REPLACE VIEW `v_users` AS select 1;'); + expect(normalizeMySQLViewDDLForEditing('v_users', '')).toBe(''); + }); + + it('normalizes driver aliases used by sidebar metadata loaders', () => { + expect(normalizeDriverType('postgresql')).toBe('postgres'); + expect(normalizeDriverType('open-gauss')).toBe('opengauss'); + expect(normalizeDriverType('InterSystemsIRIS')).toBe('iris'); + expect(isPostgresSchemaDialect('kingbase')).toBe(true); + }); + + it('resolves draggable object labels by object kind', () => { + expect(resolveSidebarObjectDragText({ + type: 'table', + title: 'fallback_table', + dataRef: { tableName: 'users' }, + })).toBe('users'); + expect(resolveSidebarObjectDragText({ + type: 'view', + title: 'fallback_view', + dataRef: { viewName: 'v_users' }, + })).toBe('v_users'); + expect(resolveSidebarObjectDragText({ + type: 'db-trigger', + title: 'trg_users_audit', + dataRef: {}, + })).toBe('trg_users_audit'); + }); +}); diff --git a/frontend/src/components/sidebarCoreUtils.ts b/frontend/src/components/sidebarCoreUtils.ts new file mode 100644 index 0000000..15f9c93 --- /dev/null +++ b/frontend/src/components/sidebarCoreUtils.ts @@ -0,0 +1,141 @@ +import type React from 'react'; +import type { SavedConnection } from '../types'; + +const SIDEBAR_CONTEXT_MENU_SAFE_GAP = 8; +export const SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH = 264; +export const SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT = 420; + +export type ExternalSQLFileModalMode = 'create' | 'rename' | 'create-directory' | 'rename-directory'; +export type SearchScope = 'smart' | 'object' | 'database' | 'host' | 'tag'; + +type SidebarObjectNodeLike = { + title?: string; + type?: string; + dataRef?: any; +}; + +export const isExternalSQLDirectoryModalMode = (mode: ExternalSQLFileModalMode): boolean => + mode === 'create-directory' || mode === 'rename-directory'; + +export const resolveSidebarContextMenuPosition = ( + x: number, + y: number, + options?: { + width?: number; + height?: number; + viewportWidth?: number; + viewportHeight?: number; + safeGap?: number; + }, +): { x: number; y: number; maxHeight: number } => { + const safeGap = options?.safeGap ?? SIDEBAR_CONTEXT_MENU_SAFE_GAP; + const viewportWidth = options?.viewportWidth ?? (typeof window === 'undefined' ? 1024 : window.innerWidth); + const viewportHeight = options?.viewportHeight ?? (typeof window === 'undefined' ? 768 : window.innerHeight); + const width = Math.max(0, options?.width ?? SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH); + const height = Math.max(0, options?.height ?? SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT); + const maxX = Math.max(safeGap, viewportWidth - width - safeGap); + const maxY = Math.max(safeGap, viewportHeight - height - safeGap); + const nextX = Math.max(safeGap, Math.min(x, maxX)); + const nextY = Math.max(safeGap, Math.min(y, maxY)); + return { + x: nextX, + y: nextY, + maxHeight: Math.max(120, viewportHeight - nextY - safeGap), + }; +}; + +export const isV2SidebarObjectNode = (node: Pick | null | undefined): boolean => { + return node?.type === 'table' + || node?.type === 'view' + || node?.type === 'materialized-view' + || node?.type === 'db-trigger' + || node?.type === 'db-event' + || node?.type === 'routine'; +}; + +export const resolveSidebarObjectDragText = ( + node: Pick | null | undefined, +): string => { + const dataRef = node?.dataRef || {}; + if (node?.type === 'table') return String(dataRef.tableName || node?.title || '').trim(); + if (node?.type === 'view' || node?.type === 'materialized-view') return String(dataRef.viewName || dataRef.tableName || node?.title || '').trim(); + if (node?.type === 'db-trigger') return String(dataRef.triggerName || node?.title || '').trim(); + if (node?.type === 'routine') return String(dataRef.routineName || node?.title || '').trim(); + if (node?.type === 'db-event') return String(dataRef.eventName || node?.title || '').trim(); + return ''; +}; + +export const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => { + if (!conn) return ''; + return JSON.stringify({ + config: conn.config || {}, + includeDatabases: conn.includeDatabases || [], + includeRedisDatabases: conn.includeRedisDatabases || [], + }); +}; + +export const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => { + const text = String(key); + return text === connectionId || text.startsWith(`${connectionId}-`); +}; + +export const normalizeDriverType = (value: string): string => { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres'; + if (normalized === 'elastic') return 'elasticsearch'; + if (normalized === 'doris') return 'diros'; + if ( + normalized === 'open_gauss' || + normalized === 'open-gauss' || + normalized === 'opengauss' + ) return 'opengauss'; + if ( + normalized === 'intersystems' || + normalized === 'intersystemsiris' || + normalized === 'inter-systems' || + normalized === 'inter-systems-iris' + ) return 'iris'; + return normalized; +}; + +export const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): string => { + const type = normalizeDriverType(conn?.config?.type || ''); + if (type !== 'custom') { + return type; + } + return normalizeDriverType(conn?.config?.driver || ''); +}; + +export const isPostgresSchemaDialect = (dialect: string): boolean => ( + ['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(normalizeDriverType(dialect)) +); + +export const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; label: string }> = [ + { value: 'smart', label: '智能' }, + { value: 'object', label: '表对象' }, + { value: 'database', label: '库' }, + { value: 'host', label: 'Host' }, + { value: 'tag', label: '标签' }, +]; + +export const SEARCH_SCOPE_LABEL_MAP: Record = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => { + acc[option.value] = option.label; + return acc; +}, {} as Record); + +export const normalizeMySQLViewDDLForEditing = (viewName: string, rawDefinition: unknown): string => { + const text = String(rawDefinition || '').trim(); + if (!text) return ''; + + const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, ''); + const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i; + if (createViewPrefixPattern.test(normalized)) { + return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`; + } + + if (/^\s*(select|with)\b/i.test(normalized)) { + return `CREATE OR REPLACE VIEW ${viewName} AS\n${normalized};`; + } + + return `${normalized};`; +};