mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 10:29:52 +08:00
♻️ refactor(sidebar): 拆分侧边栏核心工具函数
This commit is contained in:
@@ -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<TreeNode, 'type'> | 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<TreeNode, 'type' | 'title' | 'dataRef'> | 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<SearchScope, string> = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => {
|
||||
acc[option.value] = option.label;
|
||||
return acc;
|
||||
}, {} as Record<SearchScope, string>);
|
||||
|
||||
|
||||
const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
|
||||
smart: <ThunderboltOutlined />,
|
||||
object: <TableOutlined />,
|
||||
@@ -349,23 +257,6 @@ const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
|
||||
tag: <TagOutlined />,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
61
frontend/src/components/sidebarCoreUtils.test.ts
Normal file
61
frontend/src/components/sidebarCoreUtils.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
141
frontend/src/components/sidebarCoreUtils.ts
Normal file
141
frontend/src/components/sidebarCoreUtils.ts
Normal file
@@ -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<SidebarObjectNodeLike, 'type'> | 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<SidebarObjectNodeLike, 'type' | 'title' | 'dataRef'> | 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<SearchScope, string> = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => {
|
||||
acc[option.value] = option.label;
|
||||
return acc;
|
||||
}, {} as Record<SearchScope, string>);
|
||||
|
||||
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};`;
|
||||
};
|
||||
Reference in New Issue
Block a user