Files
MyGoNavi/frontend/src/utils/tabDisplay.ts
Syngnat c405eb08b5 feat(tabs): 支持标签展示配置并提示保存 SQL 文件
- 新增标签展示元素配置,支持单行、双行布局和元素排序

- 在设置面板提供标签展示入口并持久化用户配置

- 标签右键菜单增加标签设置入口并优化悬浮信息展示

- 关闭外部 SQL 文件标签前检测未保存草稿并支持保存后关闭
2026-06-02 11:16:25 +08:00

585 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ConnectionConfig, SavedConnection, TabData } from '../types';
export const TAB_DISPLAY_ELEMENT_KEYS = ['connection', 'kind', 'object', 'database', 'schema', 'host'] as const;
export type TabDisplayElementKey = typeof TAB_DISPLAY_ELEMENT_KEYS[number];
export type TabDisplayLayout = 'single' | 'double';
export interface TabDisplayLayoutSnapshot {
primaryElements: TabDisplayElementKey[];
secondaryElements: TabDisplayElementKey[];
}
export interface TabDisplaySettings {
layout: TabDisplayLayout;
primaryElements: TabDisplayElementKey[];
secondaryElements: TabDisplayElementKey[];
single?: TabDisplayLayoutSnapshot;
double?: TabDisplayLayoutSnapshot;
}
export const TAB_DISPLAY_SECONDARY_DEFAULT_KEYS: TabDisplayElementKey[] = ['connection', 'database', 'schema', 'host'];
export const TAB_DISPLAY_ELEMENT_META: Record<TabDisplayElementKey, { label: string; description: string }> = {
connection: { label: '连接名', description: '连接简称或环境名,例如 DEV' },
kind: { label: '对象类型', description: 'SQL / TABLE / VIEW 等类型标签' },
object: { label: '对象名', description: '表名、查询名、资源名等核心名称' },
database: { label: '数据库', description: '当前 DB / catalog 名称' },
schema: { label: 'Schema', description: 'schema / owner 前缀' },
host: { label: 'Host/IP', description: '连接目标地址摘要' },
};
export const DEFAULT_TAB_DISPLAY_SETTINGS: TabDisplaySettings = {
layout: 'single',
primaryElements: ['connection', 'kind', 'object'],
secondaryElements: [],
};
export const getCurrentTabDisplaySnapshot = (settings: TabDisplaySettings): TabDisplayLayoutSnapshot => ({
primaryElements: [...settings.primaryElements],
secondaryElements: [...settings.secondaryElements],
});
export const getDefaultTabDisplaySnapshot = (layout: TabDisplayLayout): TabDisplayLayoutSnapshot => {
if (layout === 'single') {
return {
primaryElements: [...DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements],
secondaryElements: [],
};
}
return {
primaryElements: [...DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements],
secondaryElements: TAB_DISPLAY_SECONDARY_DEFAULT_KEYS.filter((key) => !DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements.includes(key)),
};
};
export const getSavedTabDisplaySnapshot = (
settings: TabDisplaySettings,
layout: TabDisplayLayout,
): TabDisplayLayoutSnapshot => {
const saved = settings[layout];
if (saved) {
return {
primaryElements: [...saved.primaryElements],
secondaryElements: [...saved.secondaryElements],
};
}
if (settings.layout === layout) {
return getCurrentTabDisplaySnapshot(settings);
}
return getDefaultTabDisplaySnapshot(layout);
};
export const applyTabDisplaySettingsPatch = (
currentSettings: TabDisplaySettings,
patch: Partial<TabDisplaySettings>,
): TabDisplaySettings => {
const nextSettings = sanitizeTabDisplaySettings({
...currentSettings,
...patch,
});
const nextSnapshot = getCurrentTabDisplaySnapshot(nextSettings);
return sanitizeTabDisplaySettings({
...nextSettings,
[nextSettings.layout]: nextSnapshot,
});
};
export const switchTabDisplayLayout = (
currentSettings: TabDisplaySettings,
layout: TabDisplayLayout,
): TabDisplaySettings => {
if (layout === currentSettings.layout) {
return sanitizeTabDisplaySettings(currentSettings);
}
const currentSnapshot = getCurrentTabDisplaySnapshot(currentSettings);
const targetSnapshot = getSavedTabDisplaySnapshot(currentSettings, layout);
return sanitizeTabDisplaySettings({
...currentSettings,
[currentSettings.layout]: currentSnapshot,
layout,
primaryElements: targetSnapshot.primaryElements,
secondaryElements: targetSnapshot.secondaryElements,
[layout]: targetSnapshot,
});
};
const isTabDisplayElementKey = (value: unknown): value is TabDisplayElementKey => (
typeof value === 'string' && (TAB_DISPLAY_ELEMENT_KEYS as readonly string[]).includes(value)
);
const sanitizeTabDisplayElementList = (
value: unknown,
used: Set<TabDisplayElementKey>,
): TabDisplayElementKey[] => {
if (!Array.isArray(value)) return [];
const result: TabDisplayElementKey[] = [];
value.forEach((entry) => {
if (!isTabDisplayElementKey(entry) || used.has(entry)) return;
used.add(entry);
result.push(entry);
});
return result;
};
const sanitizeTabDisplayLayoutSnapshot = (value: unknown): TabDisplayLayoutSnapshot | null => {
if (!value || typeof value !== 'object') {
return null;
}
const raw = value as Partial<TabDisplayLayoutSnapshot>;
const used = new Set<TabDisplayElementKey>();
const primaryElements = sanitizeTabDisplayElementList(raw.primaryElements, used);
const secondaryElements = sanitizeTabDisplayElementList(raw.secondaryElements, used);
return {
primaryElements: primaryElements.length > 0 ? primaryElements : [...DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements],
secondaryElements,
};
};
export const sanitizeTabDisplaySettings = (value: unknown): TabDisplaySettings => {
if (!value || typeof value !== 'object') {
return { ...DEFAULT_TAB_DISPLAY_SETTINGS, primaryElements: [...DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements], secondaryElements: [...DEFAULT_TAB_DISPLAY_SETTINGS.secondaryElements] };
}
const raw = value as Partial<TabDisplaySettings>;
const used = new Set<TabDisplayElementKey>();
const primaryElements = sanitizeTabDisplayElementList(raw.primaryElements, used);
const secondaryElements = sanitizeTabDisplayElementList(raw.secondaryElements, used);
const result: TabDisplaySettings = {
layout: raw.layout === 'double' ? 'double' : 'single',
primaryElements: primaryElements.length > 0 ? primaryElements : [...DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements],
secondaryElements,
};
const single = sanitizeTabDisplayLayoutSnapshot(raw.single);
const double = sanitizeTabDisplayLayoutSnapshot(raw.double);
if (single) {
result.single = single;
}
if (double) {
result.double = double;
}
return result;
};
export const resolveTabDisplayElementOrder = (settings?: Partial<TabDisplaySettings> | null): TabDisplayElementKey[] => {
const sanitized = sanitizeTabDisplaySettings(settings);
const visible = [...sanitized.primaryElements, ...sanitized.secondaryElements];
return [
...visible,
...TAB_DISPLAY_ELEMENT_KEYS.filter((key) => !visible.includes(key)),
];
};
export const detectConnectionEnvLabel = (connectionName: string): string | null => {
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
if (tokens.includes('uat')) return 'UAT';
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
if (tokens.includes('sit')) return 'SIT';
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
return null;
};
const parseHostOnlyToken = (value: unknown): string[] => {
const raw = String(value || '').trim();
if (!raw) {
return [];
}
let text = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
if (text.includes('/')) {
text = text.split('/')[0];
}
if (text.includes('?')) {
text = text.split('?')[0];
}
if (text.includes('@')) {
text = text.split('@').pop() || '';
}
return text
.split(',')
.map((entry) => {
const token = entry.trim();
if (!token) return '';
if (token.startsWith('[')) {
const rightBracketIndex = token.indexOf(']');
if (rightBracketIndex > 0) {
return token.slice(0, rightBracketIndex + 1).toLowerCase();
}
}
const colonIndex = token.lastIndexOf(':');
if (colonIndex > 0) {
return token.slice(0, colonIndex).toLowerCase();
}
return token.toLowerCase();
})
.filter(Boolean);
};
export const resolveConnectionHostTokens = (config?: ConnectionConfig): string[] => {
if (!config) {
return [];
}
return Array.from(new Set([
...parseHostOnlyToken(config.host),
...(Array.isArray(config.hosts) ? config.hosts.flatMap((entry) => parseHostOnlyToken(entry)) : []),
...parseHostOnlyToken(config.uri),
]));
};
export const resolveConnectionHostSummary = (config?: ConnectionConfig): string => {
const hosts = resolveConnectionHostTokens(config);
if (hosts.length === 0) return '';
if (hosts.length === 1) return hosts[0];
return `${hosts[0]} +${hosts.length - 1}`;
};
const isRedisTab = (tab: TabData): boolean => {
return tab.type === 'redis-keys' || tab.type === 'redis-command' || tab.type === 'redis-monitor';
};
const buildRedisBaseTitle = (tab: TabData): string => {
const dbLabel = `db${tab.redisDB ?? 0}`;
if (tab.type === 'redis-command') return `命令 - ${dbLabel}`;
if (tab.type === 'redis-monitor') return `监控 - ${dbLabel}`;
return dbLabel;
};
const splitQualifiedIdentifier = (value: unknown): string[] => {
const raw = String(value || '').trim();
if (!raw) return [];
const parts: string[] = [];
let current = '';
let quote: '"' | '`' | '[' | null = null;
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index];
const next = raw[index + 1];
if (char === '\\' && next === '"') {
current += '"';
index += 1;
continue;
}
if (quote === '"') {
current += char;
if (char === '"' && next === '"') {
current += next;
index += 1;
} else if (char === '"') {
quote = null;
}
continue;
}
if (quote === '`') {
current += char;
if (char === '`' && next === '`') {
current += next;
index += 1;
} else if (char === '`') {
quote = null;
}
continue;
}
if (quote === '[') {
current += char;
if (char === ']') {
quote = null;
}
continue;
}
if (char === '"' || char === '`' || char === '[') {
quote = char;
current += char;
continue;
}
if (char === '.') {
parts.push(current.trim());
current = '';
continue;
}
current += char;
}
parts.push(current.trim());
return parts.filter(Boolean);
};
const unwrapIdentifierLabel = (value: string): string => {
let text = String(value || '').trim().replace(/\\"/g, '"');
if (!text) return '';
const first = text[0];
const last = text[text.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
text = text.slice(1, -1);
} else if (first === '[' && last === ']') {
text = text.slice(1, -1);
}
return text
.replace(/""/g, '"')
.replace(/``/g, '`')
.replace(/\]\]/g, ']')
.trim();
};
export const stripSchemaFromTabObjectLabel = (value: unknown): string => {
const raw = String(value || '').trim();
if (!raw) return '';
const parts = splitQualifiedIdentifier(raw);
const lastPart = parts[parts.length - 1] || raw;
return unwrapIdentifierLabel(lastPart) || raw;
};
const getSchemaFromTabObjectLabel = (value: unknown): string => {
const parts = splitQualifiedIdentifier(value);
if (parts.length <= 1) return '';
return parts.slice(0, -1).map((part) => unwrapIdentifierLabel(part)).filter(Boolean).join('.');
};
const replaceTitleObjectLabel = (title: string, objectName?: string): string => {
const rawTitle = String(title || '').trim();
if (!rawTitle) return rawTitle;
const rawObjectName = String(objectName || '').trim();
const displayObjectName = stripSchemaFromTabObjectLabel(rawObjectName);
if (rawObjectName && displayObjectName && displayObjectName !== rawObjectName) {
const lastIndex = rawTitle.lastIndexOf(rawObjectName);
if (lastIndex >= 0) {
return `${rawTitle.slice(0, lastIndex)}${displayObjectName}${rawTitle.slice(lastIndex + rawObjectName.length)}`;
}
}
const parenMatch = rawTitle.match(/^(.*\()([^()]*)\)(\s*)$/);
if (parenMatch) {
const objectLabel = stripSchemaFromTabObjectLabel(parenMatch[2]);
return `${parenMatch[1]}${objectLabel})${parenMatch[3]}`;
}
const colonMatch = rawTitle.match(/^([^:]+[:]\s*)(.+)$/);
if (colonMatch) {
return `${colonMatch[1]}${stripSchemaFromTabObjectLabel(colonMatch[2])}`;
}
return stripSchemaFromTabObjectLabel(rawTitle);
};
const stripSchemaFromTableOverviewTitle = (title: string): string => {
const rawTitle = String(title || '').trim();
return rawTitle.replace(/\s+\([^()]+\)\s*$/, '').trim() || rawTitle;
};
const QUERY_TAB_FALLBACK_TITLE = '新建查询';
const QUERY_TAB_TITLE_MAX_LENGTH = 28;
const getFileNameFromPath = (value: string): string => (
value.split(/[\\/]/).filter(Boolean).pop() || value
);
const isLikelyRawSqlTitle = (value: string): boolean => {
const text = value.trim();
if (!text) return false;
if (/[\r\n;]/.test(text)) return true;
return /^(select|with|insert|update|delete|merge|create|alter|drop|truncate|explain|show|desc|describe)\b/i.test(text);
};
const compactQueryTabTitle = (tab: TabData): string => {
const filePath = String(tab.filePath || '').trim();
if (filePath) {
return getFileNameFromPath(filePath);
}
const rawTitle = String(tab.title || '').trim();
const title = rawTitle && !isLikelyRawSqlTitle(rawTitle) ? rawTitle : QUERY_TAB_FALLBACK_TITLE;
if (title.length <= QUERY_TAB_TITLE_MAX_LENGTH) {
return title;
}
return `${title.slice(0, QUERY_TAB_TITLE_MAX_LENGTH - 3)}...`;
};
const buildCompactObjectTabTitle = (tab: TabData): string => {
if (tab.type === 'query') {
return compactQueryTabTitle(tab);
}
if (tab.type === 'table') {
return stripSchemaFromTabObjectLabel(tab.tableName || tab.title) || tab.title;
}
if (tab.type === 'design') {
return replaceTitleObjectLabel(tab.title, tab.tableName);
}
if (tab.type === 'table-overview') {
return stripSchemaFromTableOverviewTitle(tab.title);
}
if (tab.type === 'view-def') {
return replaceTitleObjectLabel(tab.title, tab.viewName);
}
if (tab.type === 'trigger') {
return replaceTitleObjectLabel(tab.title, tab.triggerName);
}
if (tab.type === 'event-def') {
return replaceTitleObjectLabel(tab.title, tab.eventName);
}
if (tab.type === 'routine-def') {
return replaceTitleObjectLabel(tab.title, tab.routineName);
}
return tab.title;
};
export const getTabDisplayKindLabel = (tab: TabData): string => {
if (tab.type === 'query') return 'SQL';
if (tab.type === 'table') return 'TABLE';
if (tab.type === 'design') return 'DESIGN';
if (tab.type === 'table-overview') return 'DB';
if (tab.type.startsWith('redis')) return 'REDIS';
if (tab.type.startsWith('jvm')) return 'JVM';
if (tab.type === 'trigger') return 'TRG';
if (tab.type === 'view-def') return tab.viewKind === 'materialized' ? 'MV' : 'VIEW';
if (tab.type === 'event-def') return 'EVT';
if (tab.type === 'routine-def') return 'FUNC';
return 'TAB';
};
const getTabRawObjectLabel = (tab: TabData): string => {
if (tab.type === 'query') return compactQueryTabTitle(tab);
if (tab.tableName) return tab.tableName;
if (tab.viewName) return tab.viewName;
if (tab.eventName) return tab.eventName;
if (tab.routineName) return tab.routineName;
if (tab.triggerName) return tab.triggerName;
if (tab.resourcePath) return tab.resourcePath;
if (tab.filePath) return getFileNameFromPath(tab.filePath);
if (tab.type.startsWith('redis')) return `db${tab.redisDB ?? 0}`;
return tab.title;
};
const getTabConnectionLabel = (connection?: SavedConnection): string => {
const connectionName = String(connection?.name || '').trim();
return detectConnectionEnvLabel(connectionName) || connectionName;
};
const getTabDisplayElementValue = (
key: TabDisplayElementKey,
tab: TabData,
connection?: SavedConnection,
): string => {
const rawObjectLabel = getTabRawObjectLabel(tab);
switch (key) {
case 'connection':
return getTabConnectionLabel(connection);
case 'kind':
return getTabDisplayKindLabel(tab);
case 'object':
return buildCompactObjectTabTitle({
...tab,
title: tab.type === 'table' || tab.type === 'query' ? rawObjectLabel : tab.title,
});
case 'database':
return String(tab.dbName || '').trim();
case 'schema':
return getSchemaFromTabObjectLabel(rawObjectLabel);
case 'host':
return resolveConnectionHostSummary(connection?.config);
default:
return '';
}
};
const formatTabDisplayPartValue = (key: TabDisplayElementKey, value: string): string => {
if (!value) return '';
if (key === 'connection') return `[${value}]`;
if (key === 'schema') return `SCHEMA:${value}`;
return value;
};
export interface TabDisplayPart {
key: TabDisplayElementKey;
value: string;
text: string;
}
export interface TabDisplayModel {
layout: TabDisplayLayout;
primaryParts: TabDisplayPart[];
secondaryParts: TabDisplayPart[];
primaryText: string;
secondaryText: string;
fullTitle: string;
}
const buildTabDisplayParts = (
keys: TabDisplayElementKey[],
tab: TabData,
connection?: SavedConnection,
): TabDisplayPart[] => keys
.map((key) => {
const value = getTabDisplayElementValue(key, tab, connection);
return {
key,
value,
text: formatTabDisplayPartValue(key, value),
};
})
.filter((part) => part.text);
export const buildTabDisplayModel = (
tab: TabData,
connection?: SavedConnection,
settings?: Partial<TabDisplaySettings> | null,
): TabDisplayModel => {
const sanitized = sanitizeTabDisplaySettings(settings);
const primaryParts = buildTabDisplayParts(sanitized.primaryElements, tab, connection);
const secondaryParts = buildTabDisplayParts(sanitized.secondaryElements, tab, connection);
const primaryText = primaryParts.map((part) => part.text).join(' ').trim() || buildCompactObjectTabTitle(tab);
const secondaryText = secondaryParts.map((part) => part.text).join('·').trim();
const fullTitle = [primaryText, secondaryText].filter(Boolean).join(' · ');
return {
layout: sanitized.layout,
primaryParts,
secondaryParts,
primaryText,
secondaryText,
fullTitle,
};
};
export const buildTabDisplayTitle = (
tab: TabData,
connection?: SavedConnection,
settings?: Partial<TabDisplaySettings> | null,
): string => {
if (settings) {
return buildTabDisplayModel(tab, connection, settings).fullTitle;
}
const connectionName = String(connection?.name || '').trim();
if (isRedisTab(tab)) {
const hostSummary = resolveConnectionHostSummary(connection?.config);
const identity = [connectionName, hostSummary].filter(Boolean).join(' | ');
return identity ? `[${identity}] ${buildRedisBaseTitle(tab)}` : buildRedisBaseTitle(tab);
}
const baseTitle = buildCompactObjectTabTitle(tab);
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') {
return baseTitle;
}
if (!connectionName) {
return baseTitle;
}
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${baseTitle}`;
};