feat(tab/window): 支持主工作区与结果Tab拆出为应用内浮动窗口并还原

- 新增 detached 会话态:主工作区窗口与查询结果窗口独立管理
- 主Tab支持右键拆出与垂直拖出,浮动窗可拖拽缩放并还原/关闭
- 结果Tab右键拆出为浮动结果窗,支持还原回源查询结果区
- 抽离 WorkbenchTabContent,QueryEditor 结果会话缓存避免拆出丢失
- 补充六语言文案与 detach/store 相关单元测试
This commit is contained in:
Syngnat
2026-07-09 09:08:46 +08:00
parent 5fc33102a9
commit de868a2763
18 changed files with 4397 additions and 2818 deletions

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import {
createDefaultDetachedBounds,
DETACH_TAB_DRAG_Y_THRESHOLD,
nextDetachedZIndex,
resolveDetachedWindowTitle,
shouldDetachTabByDrag,
} from './detachedWindow';
describe('detachedWindow helpers', () => {
it('computes next z-index above existing windows', () => {
expect(nextDetachedZIndex([])).toBe(1201);
expect(nextDetachedZIndex([{ zIndex: 1300 }, { zIndex: 1250 }])).toBe(1301);
});
it('detaches only when vertical drag exceeds threshold', () => {
expect(shouldDetachTabByDrag(DETACH_TAB_DRAG_Y_THRESHOLD - 1)).toBe(false);
expect(shouldDetachTabByDrag(DETACH_TAB_DRAG_Y_THRESHOLD)).toBe(true);
expect(shouldDetachTabByDrag(-DETACH_TAB_DRAG_Y_THRESHOLD)).toBe(true);
});
it('builds a default floating window bounds', () => {
const bounds = createDefaultDetachedBounds([]);
expect(bounds.width).toBeGreaterThan(0);
expect(bounds.height).toBeGreaterThan(0);
expect(bounds.zIndex).toBeGreaterThan(1200);
});
it('resolves floating window titles with object labels', () => {
expect(resolveDetachedWindowTitle({
kindLabel: '表数据',
objectLabel: 'users',
fallbackTitle: 'users',
})).toBe('表数据 · users');
expect(resolveDetachedWindowTitle({
kindLabel: 'SQL 查询',
fallbackTitle: 'Query 1',
})).toBe('Query 1');
});
});

View File

@@ -0,0 +1,129 @@
export type DetachedWindowBounds = {
x: number;
y: number;
width: number;
height: number;
zIndex: number;
};
export type DetachedWorkbenchWindow = DetachedWindowBounds & {
tabId: string;
};
export type DetachedQueryResultSnapshot = {
key: string;
sql: string;
exportSql?: string;
sourceStatementIndex?: number;
statementResultIndex?: number;
rows: any[];
columns: string[];
messages?: string[];
resultType?: 'grid' | 'message';
tableName?: string;
pkColumns: string[];
editLocator?: {
strategy?: string;
columns?: string[];
values?: Record<string, unknown>;
};
readOnly: boolean;
showRowNumberColumn?: boolean;
truncated?: boolean;
};
export type DetachedQueryResultWindow = DetachedWindowBounds & {
id: string;
sourceQueryTabId: string;
connectionId: string;
dbName?: string;
title: string;
result: DetachedQueryResultSnapshot;
};
export const DETACH_TAB_DRAG_Y_THRESHOLD = 56;
export const DEFAULT_DETACHED_WINDOW_WIDTH = 960;
export const DEFAULT_DETACHED_WINDOW_HEIGHT = 640;
export const DEFAULT_DETACHED_WINDOW_MIN_WIDTH = 480;
export const DEFAULT_DETACHED_WINDOW_MIN_HEIGHT = 320;
export const DETACHED_WINDOW_VIEWPORT_PADDING = 16;
export const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
export const nextDetachedZIndex = (windows: Array<{ zIndex?: number }>): number => {
let max = 1200;
for (const windowState of windows) {
const z = Number(windowState?.zIndex);
if (Number.isFinite(z) && z > max) {
max = z;
}
}
return max + 1;
};
const getViewportSize = () => {
if (typeof window === 'undefined') {
return {
width: DEFAULT_DETACHED_WINDOW_WIDTH + DETACHED_WINDOW_VIEWPORT_PADDING * 2,
height: DEFAULT_DETACHED_WINDOW_HEIGHT + DETACHED_WINDOW_VIEWPORT_PADDING * 2,
};
}
return {
width: window.innerWidth,
height: window.innerHeight,
};
};
export const createDefaultDetachedBounds = (
windows: Array<{ zIndex?: number }>,
preferred?: Partial<Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>>,
): DetachedWindowBounds => {
const viewport = getViewportSize();
const width = clamp(
Number(preferred?.width) || DEFAULT_DETACHED_WINDOW_WIDTH,
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
Math.max(DEFAULT_DETACHED_WINDOW_MIN_WIDTH, viewport.width - DETACHED_WINDOW_VIEWPORT_PADDING * 2),
);
const height = clamp(
Number(preferred?.height) || DEFAULT_DETACHED_WINDOW_HEIGHT,
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
Math.max(DEFAULT_DETACHED_WINDOW_MIN_HEIGHT, viewport.height - DETACHED_WINDOW_VIEWPORT_PADDING * 2),
);
const cascade = (windows.length % 8) * 28;
const defaultX = Math.max(
DETACHED_WINDOW_VIEWPORT_PADDING,
Math.round((viewport.width - width) / 2) + cascade,
);
const defaultY = Math.max(
DETACHED_WINDOW_VIEWPORT_PADDING,
Math.round((viewport.height - height) / 2) + cascade,
);
const maxX = Math.max(DETACHED_WINDOW_VIEWPORT_PADDING, viewport.width - width - DETACHED_WINDOW_VIEWPORT_PADDING);
const maxY = Math.max(DETACHED_WINDOW_VIEWPORT_PADDING, viewport.height - height - DETACHED_WINDOW_VIEWPORT_PADDING);
return {
x: clamp(Number.isFinite(Number(preferred?.x)) ? Number(preferred?.x) : defaultX, DETACHED_WINDOW_VIEWPORT_PADDING, maxX),
y: clamp(Number.isFinite(Number(preferred?.y)) ? Number(preferred?.y) : defaultY, DETACHED_WINDOW_VIEWPORT_PADDING, maxY),
width,
height,
zIndex: nextDetachedZIndex(windows),
};
};
export const shouldDetachTabByDrag = (deltaY: number, overId?: string | null): boolean => {
if (!Number.isFinite(deltaY)) return false;
// 垂直拖出超过阈值即判定为独立窗口;即使仍 hover 在其他 tab 上也可拆出
return Math.abs(deltaY) >= DETACH_TAB_DRAG_Y_THRESHOLD;
};
export const resolveDetachedWindowTitle = (params: {
kindLabel: string;
objectLabel?: string;
fallbackTitle: string;
}): string => {
const objectLabel = String(params.objectLabel || '').trim();
if (objectLabel) {
return `${params.kindLabel} · ${objectLabel}`;
}
return String(params.fallbackTitle || params.kindLabel || '').trim() || params.kindLabel;
};

View File

@@ -0,0 +1,48 @@
import type { QueryEditorResultSet } from '../components/QueryEditorResultsPanel';
type QueryEditorResultSessionSnapshot = {
resultSets: QueryEditorResultSet[];
activeResultKey: string;
isResultPanelVisible?: boolean;
};
const cache = new Map<string, QueryEditorResultSessionSnapshot>();
export const saveQueryEditorResultSession = (
tabId: string,
snapshot: QueryEditorResultSessionSnapshot,
): void => {
const id = String(tabId || '').trim();
if (!id) return;
cache.set(id, {
resultSets: Array.isArray(snapshot.resultSets) ? snapshot.resultSets : [],
activeResultKey: String(snapshot.activeResultKey || ''),
isResultPanelVisible: snapshot.isResultPanelVisible,
});
};
export const takeQueryEditorResultSession = (
tabId: string,
): QueryEditorResultSessionSnapshot | null => {
const id = String(tabId || '').trim();
if (!id) return null;
const snapshot = cache.get(id) || null;
if (snapshot) {
cache.delete(id);
}
return snapshot;
};
export const peekQueryEditorResultSession = (
tabId: string,
): QueryEditorResultSessionSnapshot | null => {
const id = String(tabId || '').trim();
if (!id) return null;
return cache.get(id) || null;
};
export const clearQueryEditorResultSession = (tabId: string): void => {
const id = String(tabId || '').trim();
if (!id) return;
cache.delete(id);
};