mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
- 新增标签展示元素配置,支持单行、双行布局和元素排序 - 在设置面板提供标签展示入口并持久化用户配置 - 标签右键菜单增加标签设置入口并优化悬浮信息展示 - 关闭外部 SQL 文件标签前检测未保存草稿并支持保存后关闭
964 lines
34 KiB
TypeScript
964 lines
34 KiB
TypeScript
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>URI、SSH、代理和驱动集中设置</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;
|