Files
MyGoNavi/frontend/src/components/TabManager.tsx
Syngnat 37ccaf7743 🐛 fix(oracle): 修复存储包定义截断与斜杠误执行
- 对象树支持序列和存储包双击及右键查看定义
- Oracle package 同时读取 PACKAGE 与 PACKAGE BODY 并生成可编辑 SQL
- SQL 拆分器跳过 SQL*Plus 独立斜杠和注释分隔符
- 补充前后端 PL/SQL 拆分与定义查看回归测试
2026-06-25 10:12:28 +08:00

1044 lines
39 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 Modal from './common/ResizableDraggableModal';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Button, Dropdown, message, Tabs, Tooltip } from 'antd';
import { AppstoreOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, PlusOutlined, RobotOutlined, SettingOutlined } from '@ant-design/icons';
import type { MenuProps, TabsProps } from 'antd';
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
import { useStore } from '../store';
import DataViewer from './DataViewer';
import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import RedisMonitor from './RedisMonitor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import TableExportWorkbench from './TableExportWorkbench';
import JVMOverview from './JVMOverview';
import JVMResourceBrowser from './JVMResourceBrowser';
import JVMAuditViewer from './JVMAuditViewer';
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
import SqlAnalysisWorkbench from './explain/SqlAnalysisWorkbench';
import type { TabData } from '../types';
import { t } from '../i18n';
import {
buildTabDisplayModel,
resolveConnectionHostSummary,
type TabDisplayPart,
type TabDisplayModel,
} from '../utils/tabDisplay';
import { ReadSQLFile, WriteSQLFile } from '../../wailsjs/go/app/App';
import {
getSQLFileTabPath,
hasSQLFileTabUnsavedChanges,
isSQLFileMissingErrorMessage,
isSQLFileMissingReadResult,
isSQLFileQueryTab,
normalizeSQLFileReadContent,
} from '../utils/sqlFileTabDirty';
import { clearSQLFileTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
const getTabKindLabel = (tab: TabData): string => {
if (tab.type === 'query') return t('tab_manager.kind_badge.query');
if (tab.type === 'table') return t('tab_manager.kind_badge.table');
if (tab.type === 'design') return t('tab_manager.kind_badge.design');
if (tab.type === 'table-overview') return t('tab_manager.kind_badge.table_overview');
if (tab.type === 'table-export') return t('tab_manager.kind_badge.table_export');
if (tab.type === 'sql-analysis') return t('tab_manager.kind_badge.sql_analysis');
if (tab.type.startsWith('redis')) return t('tab_manager.kind_badge.redis');
if (tab.type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm');
if (tab.type === 'trigger') return t('tab_manager.kind_badge.trigger');
if (tab.type === 'view-def') {
return tab.viewKind === 'materialized'
? t('tab_manager.kind_badge.materialized_view')
: t('tab_manager.kind_badge.view');
}
if (tab.type === 'event-def') return t('tab_manager.kind_badge.event');
if (tab.type === 'routine-def') return t('tab_manager.kind_badge.routine');
if (tab.type === 'sequence-def') return t('tab_manager.kind_badge.sequence');
if (tab.type === 'package-def') return t('tab_manager.kind_badge.package');
return t('tab_manager.kind_badge.fallback');
};
export const TAB_WORKBENCH_CLASS_NAME = 'tab-workbench';
const getTabKindTooltipLabel = (tab: TabData): string => {
if (tab.type === 'query') return t('tab_manager.hover.kind.query');
if (tab.type === 'table') return t('tab_manager.hover.kind.table');
if (tab.type === 'design') return t('tab_manager.hover.kind.design');
if (tab.type === 'table-overview') return t('tab_manager.hover.kind.table_overview');
if (tab.type === 'table-export') return t('tab_manager.hover.kind.table_export');
if (tab.type === 'sql-analysis') return t('tab_manager.hover.kind.sql_analysis');
if (tab.type === 'redis-keys') return t('tab_manager.hover.kind.redis_keys');
if (tab.type === 'redis-command') return t('tab_manager.hover.kind.redis_command');
if (tab.type === 'redis-monitor') return t('tab_manager.hover.kind.redis_monitor');
if (tab.type === 'jvm-overview') return t('tab_manager.hover.kind.jvm_overview');
if (tab.type === 'jvm-resource') return t('tab_manager.hover.kind.jvm_resource');
if (tab.type === 'jvm-audit') return t('tab_manager.hover.kind.jvm_audit');
if (tab.type === 'jvm-diagnostic') return t('tab_manager.hover.kind.jvm_diagnostic');
if (tab.type === 'jvm-monitoring') return t('tab_manager.hover.kind.jvm_monitoring');
if (tab.type === 'trigger') return t('tab_manager.hover.kind.trigger');
if (tab.type === 'view-def') {
return tab.viewKind === 'materialized'
? t('tab_manager.hover.kind.materialized_view')
: t('tab_manager.hover.kind.view');
}
if (tab.type === 'event-def') return t('tab_manager.hover.kind.event');
if (tab.type === 'routine-def') return t('tab_manager.hover.kind.routine');
if (tab.type === 'sequence-def') return t('tab_manager.hover.kind.sequence');
if (tab.type === 'package-def') return t('tab_manager.hover.kind.package');
return t('tab_manager.hover.kind.fallback');
};
const getTabObjectLabel = (tab: TabData): string => {
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.sequenceName) return tab.sequenceName;
if (tab.packageName) return tab.packageName;
if (tab.triggerName) return tab.triggerName;
if (tab.resourcePath) return tab.resourcePath;
if (tab.filePath) return tab.filePath;
if (tab.type === 'sql-analysis') return tab.title;
if (tab.type.startsWith('redis')) return `db${tab.redisDB ?? 0}`;
return '';
};
const getCloseOtherTabIds = (tabs: TabData[], id: string): string[] =>
tabs.filter((tab) => tab.id !== id).map((tab) => tab.id);
const getCloseTabsToLeftIds = (tabs: TabData[], id: string): string[] => {
const index = tabs.findIndex((tab) => tab.id === id);
if (index <= 0) return [];
return tabs.slice(0, index).map((tab) => tab.id);
};
const getCloseTabsToRightIds = (tabs: TabData[], id: string): string[] => {
const index = tabs.findIndex((tab) => tab.id === id);
if (index < 0 || index >= tabs.length - 1) return [];
return tabs.slice(index + 1).map((tab) => tab.id);
};
export const stopTabHoverDragPropagation = (event: React.SyntheticEvent<HTMLElement>) => {
event.stopPropagation();
};
export const resolveTabHoverOpen = (isHoverInfoOpen: boolean, isTabMenuOpen: boolean) =>
isHoverInfoOpen && !isTabMenuOpen;
export const openTabDisplaySettings = () => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent('gonavi:open-tab-display-settings'));
};
export const shouldShowV2ConnectionLabel = (displayTitle: string, connectionLabel?: string): boolean => {
const normalizedConnectionLabel = String(connectionLabel || '').trim();
if (!normalizedConnectionLabel) {
return false;
}
const normalizedDisplayTitle = String(displayTitle || '').trim();
if (!normalizedDisplayTitle) {
return true;
}
const escapedConnectionLabel = normalizedConnectionLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const prefixedConnectionPattern = new RegExp(`^\\[${escapedConnectionLabel}(?:\\s*[|\\]])`, 'i');
return !prefixedConnectionPattern.test(normalizedDisplayTitle);
};
export const resolveTabHoverTitle = (displayModel: TabDisplayModel | undefined, fallbackTitle: string): string => {
if (!displayModel) {
return fallbackTitle;
}
const objectPart = [...displayModel.primaryParts, ...displayModel.secondaryParts]
.find((part) => part.key === 'object');
if (objectPart?.text) {
return objectPart.text;
}
const primaryText = displayModel.primaryParts
.filter((part) => part.key !== 'kind')
.map((part) => part.text)
.join(' ')
.trim();
return primaryText || displayModel.primaryText || fallbackTitle;
};
type TabHoverInfoProps = {
tab: TabData;
displayModel?: TabDisplayModel;
displayTitle: string;
connectionLabel?: string;
hostSummary?: string;
};
export const TabHoverInfo: React.FC<TabHoverInfoProps> = ({
tab,
displayModel,
displayTitle,
connectionLabel,
hostSummary,
}) => {
const objectLabel = getTabObjectLabel(tab);
const hoverTitle = resolveTabHoverTitle(displayModel, displayTitle);
const schemaPart = displayModel
? [...displayModel.primaryParts, ...displayModel.secondaryParts].find((part) => part.key === 'schema')
: undefined;
const rows = [
[t('tab_manager.hover.label.type'), getTabKindTooltipLabel(tab)],
[t('tab_manager.hover.label.connection'), connectionLabel || t('tab_manager.hover.fallback.unbound_connection')],
['Host', hostSummary || t('tab_manager.hover.fallback.host_not_configured')],
[t('tab_manager.hover.label.database'), tab.dbName || t('tab_manager.hover.fallback.database_not_specified')],
['Schema', schemaPart?.value],
[t('tab_manager.hover.label.object'), objectLabel],
].filter(([, value]) => Boolean(value));
return (
<div
className="gn-v2-tab-hover-card"
data-tab-hover-info="true"
onPointerDown={stopTabHoverDragPropagation}
onPointerMove={stopTabHoverDragPropagation}
onPointerUp={stopTabHoverDragPropagation}
onPointerDownCapture={stopTabHoverDragPropagation}
onPointerUpCapture={stopTabHoverDragPropagation}
onMouseDown={stopTabHoverDragPropagation}
onMouseMove={stopTabHoverDragPropagation}
onMouseUp={stopTabHoverDragPropagation}
onClick={stopTabHoverDragPropagation}
onClickCapture={stopTabHoverDragPropagation}
onTouchStart={stopTabHoverDragPropagation}
onTouchMove={stopTabHoverDragPropagation}
onTouchEnd={stopTabHoverDragPropagation}
>
<div className="gn-v2-tab-hover-head">
<span>{getTabKindLabel(tab)}</span>
<strong>{hoverTitle}</strong>
</div>
<div className="gn-v2-tab-hover-rows">
{rows.map(([label, value]) => (
<div className="gn-v2-tab-hover-row" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</div>
);
};
type SortableTabLabelProps = {
tab: TabData;
displayModel: TabDisplayModel;
displayTitle: string;
menuItems: MenuProps['items'];
connectionLabel?: string;
hostSummary?: string;
isV2Ui?: boolean;
onClose?: () => void;
};
const renderV2TabDisplayPart = (part: TabDisplayPart) => {
if (part.key === 'kind') {
return (
<span className="gn-v2-tab-kind" key={part.key}>
{part.text}
</span>
);
}
return (
<span className={`gn-v2-tab-label-part gn-v2-tab-label-part-${part.key}`} key={part.key}>
{part.text}
</span>
);
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
tab,
displayModel,
displayTitle,
menuItems,
connectionLabel,
hostSummary,
isV2Ui,
onClose,
}) => {
const [isHoverInfoOpen, setIsHoverInfoOpen] = useState(false);
const [isTabMenuOpen, setIsTabMenuOpen] = useState(false);
const handleTabLabelContextMenu = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
setIsHoverInfoOpen(false);
setIsTabMenuOpen(true);
};
const handleTabMenuOpenChange = (open: boolean) => {
setIsTabMenuOpen(open);
setIsHoverInfoOpen(false);
};
const handleHoverInfoOpenChange = (open: boolean) => {
setIsHoverInfoOpen(open && !isTabMenuOpen);
};
const tabDisplayPartCount = displayModel.primaryParts.length + displayModel.secondaryParts.length;
const showSecondaryLine = isV2Ui && displayModel.layout === 'double' && Boolean(displayModel.secondaryText);
const labelNode = (
<span
className={`tab-dnd-label${isV2Ui ? ' gn-v2-tab-label' : ''}${showSecondaryLine ? ' gn-v2-tab-label-double' : ''}${tabDisplayPartCount >= 4 ? ' gn-v2-tab-label-rich' : ''}`}
onContextMenu={handleTabLabelContextMenu}
title={isV2Ui ? undefined : displayTitle}
>
{isV2Ui ? (
<span className="gn-v2-tab-label-content">
<span className="gn-v2-tab-label-main tab-title-text">
{displayModel.primaryParts.length > 0
? displayModel.primaryParts.map(renderV2TabDisplayPart)
: displayModel.primaryText}
</span>
{showSecondaryLine ? (
<span className="gn-v2-tab-label-secondary" title={displayModel.secondaryText}>
{displayModel.secondaryText}
</span>
) : null}
</span>
) : (
<span className="tab-title-text">{displayTitle}</span>
)}
{isV2Ui && onClose ? (
<button
type="button"
className="gn-v2-tab-close"
aria-label={t('tab_manager.close_aria', { title: displayTitle })}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onClose();
}}
>
<CloseOutlined />
</button>
) : null}
</span>
);
const wrappedLabel = isV2Ui ? (
<Tooltip
title={(
<TabHoverInfo
tab={tab}
displayModel={displayModel}
displayTitle={displayTitle}
connectionLabel={connectionLabel}
hostSummary={hostSummary}
/>
)}
placement="bottomLeft"
mouseEnterDelay={1.2}
open={resolveTabHoverOpen(isHoverInfoOpen, isTabMenuOpen)}
onOpenChange={handleHoverInfoOpenChange}
destroyOnHidden
rootClassName="gn-v2-tab-hover-tooltip"
>
{labelNode}
</Tooltip>
) : labelNode;
return (
<Dropdown
menu={{ items: menuItems }}
trigger={['contextMenu']}
onOpenChange={handleTabMenuOpenChange}
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
>
{wrappedLabel}
</Dropdown>
);
};
type DraggableTabNodeProps = {
node: React.ReactElement;
};
const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
const tabId = String(node.key || '').trim();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
const style: React.CSSProperties = {
...(node.props.style || {}),
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
opacity: isDragging ? 0.88 : 1,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
zIndex: isDragging ? 2 : node.props.style?.zIndex,
};
return React.cloneElement(node, {
ref: setNodeRef,
style,
...attributes,
...listeners,
className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`,
});
};
const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({ tab, isActive }) => {
if (tab.type === 'query') {
return <QueryEditor tab={tab} isActive={isActive} />;
}
if (tab.type === 'table') {
return <DataViewer tab={tab} isActive={isActive} />;
}
if (tab.type === 'design') {
return <TableDesigner tab={tab} />;
}
if (tab.type === 'redis-keys') {
return <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
}
if (tab.type === 'redis-command') {
return <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
}
if (tab.type === 'redis-monitor') {
return <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
}
if (tab.type === 'trigger') {
return <TriggerViewer tab={tab} />;
}
if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def' || tab.type === 'sequence-def' || tab.type === 'package-def') {
return <DefinitionViewer tab={tab} />;
}
if (tab.type === 'table-overview') {
return <TableOverview tab={tab} />;
}
if (tab.type === 'table-export') {
return <TableExportWorkbench tab={tab} />;
}
if (tab.type === 'sql-analysis') {
return <SqlAnalysisWorkbench tab={tab} />;
}
if (tab.type === 'jvm-overview') {
return <JVMOverview tab={tab} />;
}
if (tab.type === 'jvm-resource') {
return <JVMResourceBrowser tab={tab} />;
}
if (tab.type === 'jvm-audit') {
return <JVMAuditViewer tab={tab} />;
}
if (tab.type === 'jvm-diagnostic') {
return <JVMDiagnosticConsole tab={tab} />;
}
if (tab.type === 'jvm-monitoring') {
return <JVMMonitoringDashboard tab={tab} />;
}
return null;
});
const TabManager: React.FC = React.memo(() => {
const tabs = useStore(state => state.tabs);
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const languagePreference = useStore(state => state.languagePreference);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const addTab = useStore(state => state.addTab);
const closeTab = useStore(state => state.closeTab);
const closeOtherTabs = useStore(state => state.closeOtherTabs);
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
const closeTabsToRight = useStore(state => state.closeTabsToRight);
const closeAllTabs = useStore(state => state.closeAllTabs);
const moveTab = useStore(state => state.moveTab);
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const suppressClickUntilRef = useRef<number>(0);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const isV2Ui = appearance.uiVersion === 'v2';
const hasTabs = tabs.length > 0;
const pendingCloseTabIdsRef = useRef<Set<string>>(new Set());
const onChange = (newActiveKey: string) => {
setActiveTab(newActiveKey);
};
const requestCloseSQLFileTabs = useCallback(async (
targetTabs: TabData[],
closeConfirmedTabs: () => void,
) => {
const candidateTabs = targetTabs.filter(isSQLFileQueryTab);
if (candidateTabs.length === 0) {
closeConfirmedTabs();
return;
}
const closeConfirmedTabsAndClearDrafts = () => {
closeConfirmedTabs();
candidateTabs.forEach((tab) => clearSQLFileTabDraft(tab.id));
};
const dirtyTabs: Array<{ tab: TabData; draft: string }> = [];
const missingFileTabs: Array<{ tab: TabData; filePath: string }> = [];
for (const tab of candidateTabs) {
const filePath = getSQLFileTabPath(tab);
if (!filePath) continue;
try {
const res = await ReadSQLFile(filePath);
if (!res.success) {
if (isSQLFileMissingReadResult(res)) {
missingFileTabs.push({ tab, filePath });
continue;
}
message.error(t('tab_manager.sql_file_close.read_failed_cancel_close', { detail: res.message || filePath }));
return;
}
const draft = getSQLFileTabDraft(tab.id, String(tab.query ?? ''));
if (hasSQLFileTabUnsavedChanges({ ...tab, query: draft }, normalizeSQLFileReadContent(res.data))) {
dirtyTabs.push({ tab, draft });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (isSQLFileMissingErrorMessage(errorMessage)) {
missingFileTabs.push({ tab, filePath });
continue;
}
message.error(t('tab_manager.sql_file_close.read_failed_cancel_close', { detail: errorMessage }));
return;
}
}
const confirmDirtyTabsOrClose = () => {
if (dirtyTabs.length === 0) {
closeConfirmedTabsAndClearDrafts();
return;
}
const firstDirtyTab = dirtyTabs[0].tab;
const dirtyFilePath = getSQLFileTabPath(firstDirtyTab);
const dirtyLabel = dirtyTabs.length === 1
? t('tab_manager.sql_file_close.dirty_single_label', { title: firstDirtyTab.title || dirtyFilePath })
: t('tab_manager.sql_file_close.dirty_multiple_label', { count: dirtyTabs.length });
let destroyConfirm: (() => void) | null = null;
const confirmRef = Modal.confirm({
title: t('tab_manager.sql_file_close.save_confirm_title'),
content: t('tab_manager.sql_file_close.save_confirm_content', { label: dirtyLabel }),
okText: t('tab_manager.sql_file_close.save_and_close'),
cancelText: t('common.cancel'),
closable: true,
maskClosable: true,
okButtonProps: { type: 'primary' },
footer: (_, { OkBtn, CancelBtn }) => (
<>
<Button
onClick={() => {
destroyConfirm?.();
closeConfirmedTabsAndClearDrafts();
}}
>
{t('tab_manager.sql_file_close.discard')}
</Button>
<CancelBtn />
<OkBtn />
</>
),
onOk: async () => {
try {
for (const { tab, draft } of dirtyTabs) {
const filePath = getSQLFileTabPath(tab);
if (!filePath) continue;
const res = await WriteSQLFile(filePath, draft);
if (!res.success) {
throw new Error(t('tab_manager.sql_file_close.save_failed', {
title: tab.title || filePath,
detail: res.message || t('tab_manager.sql_file_close.unknown_error'),
}));
}
}
message.success(t('tab_manager.sql_file_close.saved'));
closeConfirmedTabsAndClearDrafts();
} catch (error) {
message.error(error instanceof Error ? error.message : String(error));
throw error;
}
},
});
destroyConfirm = confirmRef.destroy;
};
if (missingFileTabs.length > 0) {
const firstMissing = missingFileTabs[0];
const missingLabel = missingFileTabs.length === 1
? t('tab_manager.sql_file_close.missing_single_label', { title: firstMissing.tab.title || firstMissing.filePath })
: t('tab_manager.sql_file_close.missing_multiple_label', { count: missingFileTabs.length });
Modal.confirm({
title: t('tab_manager.sql_file_close.missing_confirm_title'),
content: t('tab_manager.sql_file_close.missing_confirm_content', { label: missingLabel }),
okText: dirtyTabs.length > 0 ? t('tab_manager.sql_file_close.continue_close') : t('tab_manager.sql_file_close.close_tabs'),
cancelText: t('common.cancel'),
closable: true,
maskClosable: true,
okButtonProps: { danger: true },
onOk: () => {
confirmDirtyTabsOrClose();
},
});
return;
}
confirmDirtyTabsOrClose();
}, []);
const closeTabsWithSQLFilePrompt = useCallback((targetIds: string[], closeConfirmedTabs: () => void) => {
const uniqueIds = Array.from(new Set(targetIds.map((id) => String(id || '').trim()).filter(Boolean)));
if (uniqueIds.length === 0) return;
const dedupeKey = uniqueIds.slice().sort().join('\n');
if (pendingCloseTabIdsRef.current.has(dedupeKey)) return;
pendingCloseTabIdsRef.current.add(dedupeKey);
const targetTabs = tabs.filter((tab) => uniqueIds.includes(tab.id));
void requestCloseSQLFileTabs(targetTabs, closeConfirmedTabs).finally(() => {
pendingCloseTabIdsRef.current.delete(dedupeKey);
});
}, [requestCloseSQLFileTabs, tabs]);
const onEdit = (targetKey: React.MouseEvent | React.KeyboardEvent | string, action: 'add' | 'remove') => {
if (action === 'remove') {
const id = String(targetKey || '');
closeTabsWithSQLFilePrompt([id], () => closeTab(id));
}
};
const handleDragStart = (event: DragStartEvent) => {
const sourceId = String(event.active.id || '').trim();
setDraggingTabId(sourceId || null);
};
const handleDragEnd = (event: DragEndEvent) => {
const sourceId = String(event.active.id || '').trim();
const targetId = String(event.over?.id || '').trim();
setDraggingTabId(null);
if (!sourceId || !targetId || sourceId === targetId) {
return;
}
suppressClickUntilRef.current = Date.now() + 120;
moveTab(sourceId, targetId);
};
const handleDragCancel = () => {
setDraggingTabId(null);
};
React.useEffect(() => {
const handleGlobalInsertSql = (e: any) => {
const { sql, runImmediately, connectionId: eventConnId, dbName: eventDbName } = e.detail;
if (!sql) return;
const activeTab = tabs.find(t => t.id === activeTabId);
// 🔧 runImmediately点击"执行")始终新建独立 tab避免追加到已有 tab 导致 SQL 重复
if (runImmediately) {
const newTabId = 'tab-' + Date.now();
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
addTab({
id: newTabId,
type: 'query',
title: t('query.new'),
query: sql,
connectionId: resolvedConnId,
dbName: resolvedDbName
});
setActiveTab(newTabId);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
detail: { tabId: newTabId, sql, runImmediately: true, connectionId: resolvedConnId, dbName: resolvedDbName }
}));
}, 300);
return;
}
// 插入模式:追加到已有 tab 或新建 tab
if (activeTab && activeTab.type === 'query') {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
detail: { tabId: activeTab.id, sql, runImmediately: false, connectionId: eventConnId, dbName: eventDbName }
}));
} else {
const newTabId = 'tab-' + Date.now();
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
addTab({
id: newTabId,
type: 'query',
title: t('query.new'),
query: sql,
connectionId: resolvedConnId,
dbName: resolvedDbName
});
setActiveTab(newTabId);
}
};
window.addEventListener('gonavi:insert-sql', handleGlobalInsertSql);
return () => window.removeEventListener('gonavi:insert-sql', handleGlobalInsertSql);
}, [tabs, activeTabId, addTab, setActiveTab, connections]);
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
const hasDoubleLineTabLabel = useMemo(() => (
tabs.some((tab) => {
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t);
return displayModel.layout === 'double' && Boolean(displayModel.secondaryText);
})
), [appearance.tabDisplay, connections, tabs]);
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
<DefaultTabBar {...tabBarProps}>
{(node) => <DraggableTabNode key={node.key} node={node} />}
</DefaultTabBar>
);
const items = useMemo(() => tabs.map((tab, index) => {
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t);
const displayTitle = displayModel.fullTitle;
const hostSummary = resolveConnectionHostSummary(connection?.config);
const tabIsActive = tab.id === activeTabId;
const menuItems: MenuProps['items'] = [
{
key: 'tab-display-settings',
icon: <SettingOutlined />,
label: t('tab_manager.menu.tab_display_settings'),
onClick: openTabDisplaySettings,
},
{ type: 'divider' },
{
key: 'close-other',
label: t('tab_manager.menu.close_other'),
disabled: tabs.length <= 1,
onClick: () => closeTabsWithSQLFilePrompt(getCloseOtherTabIds(tabs, tab.id), () => closeOtherTabs(tab.id)),
},
{
key: 'close-left',
label: t('tab_manager.menu.close_left'),
disabled: index === 0,
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToLeftIds(tabs, tab.id), () => closeTabsToLeft(tab.id)),
},
{
key: 'close-right',
label: t('tab_manager.menu.close_right'),
disabled: index === tabs.length - 1,
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(tabs, tab.id), () => closeTabsToRight(tab.id)),
},
{ type: 'divider' },
{
key: 'close-all',
label: t('tab_manager.menu.close_all'),
disabled: tabs.length === 0,
onClick: () => closeTabsWithSQLFilePrompt(tabs.map((item) => item.id), () => closeAllTabs()),
},
];
return {
label: (
<SortableTabLabel
tab={tab}
displayModel={displayModel}
displayTitle={displayTitle}
menuItems={menuItems}
connectionLabel={connection?.name}
hostSummary={hostSummary}
isV2Ui={isV2Ui}
onClose={() => closeTabsWithSQLFilePrompt([tab.id], () => closeTab(tab.id))}
/>
),
key: tab.id,
closable: !isV2Ui,
children: <TabContent tab={tab} isActive={tabIsActive} />,
};
}), [tabs, connections, appearance.tabDisplay, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, closeTabsWithSQLFilePrompt, isV2Ui, languagePreference]);
const handleOpenConnectionModal = () => {
const target = document.querySelector<HTMLButtonElement>('[data-gonavi-create-connection-action="true"]');
target?.click();
};
const handleOpenAI = () => {
setAIPanelVisible(true);
};
const EmptyWorkbench = (
<div className="gn-v2-empty-workbench">
<section className="gn-v2-empty-hero" aria-label={t('tab_manager.empty.aria.start_workbench')}>
<div className="gn-v2-empty-eyebrow">
<span>{t('tab_manager.empty.eyebrow.workbench')}</span>
<span>{t('tab_manager.empty.eyebrow.connections', { count: connections.length })}</span>
</div>
<h1>{t('tab_manager.empty.hero.title')}</h1>
<p>{t('tab_manager.empty.hero.description')}</p>
<div className="gn-v2-empty-actions">
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenConnectionModal}>
{t('connection.new')}
</Button>
<Button icon={<ConsoleSqlOutlined />} onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
{t('query.new')}
</Button>
<Button icon={<RobotOutlined />} onClick={handleOpenAI}>
{t('tab_manager.empty.action.open_ai')}
</Button>
</div>
</section>
<section className="gn-v2-empty-panel" aria-label={t('tab_manager.empty.quick.aria')}>
<div className="gn-v2-panel-heading">
<span>{t('tab_manager.empty.quick.heading')}</span>
<AppstoreOutlined />
</div>
<button type="button" onClick={handleOpenConnectionModal}>
<DatabaseOutlined />
<span>
<strong>{t('tab_manager.empty.quick.configure_source.title')}</strong>
<small>{t('tab_manager.empty.quick.configure_source.description')}</small>
</span>
</button>
<button type="button" onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
<ConsoleSqlOutlined />
<span>
<strong>{t('tab_manager.empty.quick.sql_workspace.title')}</strong>
<small>{t('tab_manager.empty.quick.sql_workspace.description')}</small>
</span>
</button>
<button type="button" onClick={handleOpenAI}>
<RobotOutlined />
<span>
<strong>{t('tab_manager.empty.quick.ai_assist.title')}</strong>
<small>{t('tab_manager.empty.quick.ai_assist.description')}</small>
</span>
</button>
</section>
</div>
);
return (
<div className={`${TAB_WORKBENCH_CLASS_NAME}${isV2Ui ? ' gn-v2-tab-workbench' : ''}`}>
<style>{`
.${TAB_WORKBENCH_CLASS_NAME} {
height: 100%;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-tabs {
height: 100%;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-tabs .ant-tabs-nav {
flex: 0 0 auto;
margin: 0;
}
.main-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-content {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-tabs .ant-tabs-tabpane > div {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
}
.main-tabs .ant-tabs-tabpane-hidden {
display: none !important;
}
.main-tabs .ant-tabs-nav::before {
border-bottom: 1px solid ${tabsNavBorderColor} !important;
}
.main-tabs .ant-tabs-tab {
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease;
}
.main-tabs .tab-dnd-label {
user-select: none;
-webkit-user-select: none;
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
}
.main-tabs .tab-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.main-tabs .tab-dnd-node.is-dragging,
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
cursor: grabbing !important;
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible {
outline: none !important;
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.72);
background: rgba(255, 214, 102, 0.16);
}
body[data-theme='light'] .main-tabs .ant-tabs-tab-btn:focus-visible {
outline: none !important;
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
background: rgba(9, 109, 217, 0.08);
}
body[data-theme='light'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(24, 144, 255, 0.10) !important;
border-color: rgba(24, 144, 255, 0.28) !important;
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(255, 214, 102, 0.12) !important;
border-color: rgba(255, 214, 102, 0.4) !important;
}
body[data-ui-version='v2'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: var(--gn-bg-panel) !important;
border-color: var(--gn-br-2) !important;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-tooltip .ant-tooltip-inner {
min-width: 260px;
padding: 0;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-tooltip {
pointer-events: auto;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-card {
--gn-v2-tab-hover-grid-columns: 56px minmax(0, 1fr);
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
color: var(--gn-fg-2);
cursor: text;
user-select: text;
-webkit-user-select: text;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-card * {
user-select: text;
-webkit-user-select: text;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-head {
display: grid;
grid-template-columns: var(--gn-v2-tab-hover-grid-columns);
align-items: start;
gap: 8px;
min-width: 0;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-head > span {
justify-self: start;
padding: 2px 6px;
border-radius: 5px;
background: var(--gn-bg-active);
color: var(--gn-accent-2);
font-family: var(--gn-font-mono);
font-size: 10px;
font-weight: 700;
line-height: 14px;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-head > strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--gn-fg-1);
font-size: var(--gn-font-size-sm, 12px);
font-weight: 700;
line-height: 18px;
white-space: normal;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-rows {
display: grid;
gap: 5px;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-row {
display: grid;
grid-template-columns: var(--gn-v2-tab-hover-grid-columns);
align-items: start;
gap: 8px;
font-size: var(--gn-font-size-sm, 12px);
line-height: 18px;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-row > span {
color: var(--gn-fg-5);
}
body[data-ui-version='v2'] .gn-v2-tab-hover-row > strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--gn-fg-2);
font-weight: 600;
}
`}</style>
{isV2Ui && !hasTabs ? (
EmptyWorkbench
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<Tabs
className={`main-tabs${isV2Ui ? ' gn-v2-main-tabs' : ''}${hasDoubleLineTabLabel ? ' gn-v2-main-tabs-double' : ''}`}
type="editable-card"
destroyOnHidden={false}
onChange={(newActiveKey) => {
if (Date.now() < suppressClickUntilRef.current) return;
onChange(newActiveKey);
}}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
hideAdd
renderTabBar={renderTabBar}
/>
</SortableContext>
</DndContext>
)}
</div>
);
});
export default TabManager;