mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-27 00:38:47 +08:00
- 对象树支持序列和存储包双击及右键查看定义 - Oracle package 同时读取 PACKAGE 与 PACKAGE BODY 并生成可编辑 SQL - SQL 拆分器跳过 SQL*Plus 独立斜杠和注释分隔符 - 补充前后端 PL/SQL 拆分与定义查看回归测试
1044 lines
39 KiB
TypeScript
1044 lines
39 KiB
TypeScript
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;
|