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

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

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

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

964 lines
34 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 React, { useCallback, useMemo, useRef, useState } from 'react';
import { Button, Dropdown, message, Modal, 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 JVMOverview from './JVMOverview';
import JVMResourceBrowser from './JVMResourceBrowser';
import JVMAuditViewer from './JVMAuditViewer';
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
import type { TabData } from '../types';
import {
buildTabDisplayModel,
getTabDisplayKindLabel,
resolveConnectionHostSummary,
type TabDisplayPart,
type TabDisplayModel,
} from '../utils/tabDisplay';
import { ReadSQLFile, WriteSQLFile } from '../../wailsjs/go/app/App';
import {
getSQLFileTabPath,
hasSQLFileTabUnsavedChanges,
isSQLFileQueryTab,
normalizeSQLFileReadContent,
} from '../utils/sqlFileTabDirty';
import { clearSQLFileTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
const getTabKindLabel = getTabDisplayKindLabel;
export const TAB_WORKBENCH_CLASS_NAME = 'tab-workbench';
const getTabKindTooltipLabel = (tab: TabData): string => {
if (tab.type === 'query') return 'SQL 查询';
if (tab.type === 'table') return '表数据';
if (tab.type === 'design') return '表设计';
if (tab.type === 'table-overview') return '表概览';
if (tab.type === 'redis-keys') return 'Redis Key';
if (tab.type === 'redis-command') return 'Redis 命令';
if (tab.type === 'redis-monitor') return 'Redis 监控';
if (tab.type === 'jvm-overview') return 'JVM 概览';
if (tab.type === 'jvm-resource') return 'JVM 资源';
if (tab.type === 'jvm-audit') return 'JVM 审计';
if (tab.type === 'jvm-diagnostic') return 'JVM 诊断';
if (tab.type === 'jvm-monitoring') return 'JVM 监控';
if (tab.type === 'trigger') return '触发器';
if (tab.type === 'view-def') return tab.viewKind === 'materialized' ? '物化视图' : '视图';
if (tab.type === 'event-def') return '事件';
if (tab.type === 'routine-def') return '函数 / 过程';
return '标签页';
};
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.triggerName) return tab.triggerName;
if (tab.resourcePath) return tab.resourcePath;
if (tab.filePath) return tab.filePath;
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 = [
['类型', getTabKindTooltipLabel(tab)],
['连接', connectionLabel || '未绑定连接'],
['Host', hostSummary || '未配置'],
['数据库', tab.dbName || '未指定'],
['Schema', schemaPart?.value],
['对象', 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={`关闭 ${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') {
return <DefinitionViewer tab={tab} />;
}
if (tab.type === 'table-overview') {
return <TableOverview 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 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 }> = [];
for (const tab of candidateTabs) {
const filePath = getSQLFileTabPath(tab);
if (!filePath) continue;
try {
const res = await ReadSQLFile(filePath);
if (!res.success) {
message.error(`读取 SQL 文件失败,已取消关闭:${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) {
message.error('读取 SQL 文件失败,已取消关闭:' + (error instanceof Error ? error.message : String(error)));
return;
}
}
if (dirtyTabs.length === 0) {
closeConfirmedTabsAndClearDrafts();
return;
}
const firstDirtyTab = dirtyTabs[0].tab;
const dirtyFilePath = getSQLFileTabPath(firstDirtyTab);
const dirtyLabel = dirtyTabs.length === 1
? `${firstDirtyTab.title || dirtyFilePath}`
: `${dirtyTabs.length} 个 SQL 文件`;
let destroyConfirm: (() => void) | null = null;
const confirmRef = Modal.confirm({
title: '保存 SQL 文件修改?',
content: `${dirtyLabel} 有未保存修改,是否保存后再关闭?`,
okText: '保存并关闭',
cancelText: '取消',
closable: true,
maskClosable: true,
okButtonProps: { type: 'primary' },
footer: (_, { OkBtn, CancelBtn }) => (
<>
<Button
onClick={() => {
destroyConfirm?.();
closeConfirmedTabsAndClearDrafts();
}}
>
</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(`保存 ${tab.title || filePath} 失败:${res.message || '未知错误'}`);
}
}
message.success('SQL 文件已保存');
closeConfirmedTabsAndClearDrafts();
} catch (error) {
message.error(error instanceof Error ? error.message : String(error));
throw error;
}
},
});
destroyConfirm = confirmRef.destroy;
}, []);
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: '新建查询',
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: '新建查询',
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);
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);
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: '标签设置',
onClick: openTabDisplaySettings,
},
{ type: 'divider' },
{
key: 'close-other',
label: '关闭其他页',
disabled: tabs.length <= 1,
onClick: () => closeTabsWithSQLFilePrompt(getCloseOtherTabIds(tabs, tab.id), () => closeOtherTabs(tab.id)),
},
{
key: 'close-left',
label: '关闭左侧',
disabled: index === 0,
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToLeftIds(tabs, tab.id), () => closeTabsToLeft(tab.id)),
},
{
key: 'close-right',
label: '关闭右侧',
disabled: index === tabs.length - 1,
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(tabs, tab.id), () => closeTabsToRight(tab.id)),
},
{ type: 'divider' },
{
key: 'close-all',
label: '关闭所有',
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]);
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="GoNavi 起始工作台">
<div className="gn-v2-empty-eyebrow">
<span>WORKBENCH</span>
<span>{connections.length} connections</span>
</div>
<h1></h1>
<p> AI </p>
<div className="gn-v2-empty-actions">
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenConnectionModal}>
</Button>
<Button icon={<ConsoleSqlOutlined />} onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
</Button>
<Button icon={<RobotOutlined />} onClick={handleOpenAI}>
AI
</Button>
</div>
</section>
<section className="gn-v2-empty-panel" aria-label="快捷工作流">
<div className="gn-v2-panel-heading">
<span></span>
<AppstoreOutlined />
</div>
<button type="button" onClick={handleOpenConnectionModal}>
<DatabaseOutlined />
<span>
<strong></strong>
<small>URISSH</small>
</span>
</button>
<button type="button" onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
<ConsoleSqlOutlined />
<span>
<strong> SQL </strong>
<small></small>
</span>
</button>
<button type="button" onClick={handleOpenAI}>
<RobotOutlined />
<span>
<strong> AI </strong>
<small> SQL</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;