🐛 fix(sidebar): 修复表默认打开行为与左树滚动

- 工具设置新增双击表名行为开关,默认打开表数据,可切换到对象设计

- 左侧树、表概览卡片视图和列表视图统一按开关打开数据页内对象设计

- 调整 V2 左侧树横向滚动条布局,使滚动条保持在底部可见
This commit is contained in:
Syngnat
2026-06-26 17:28:49 +08:00
parent 7c9cf95698
commit 3ed45fab41
10 changed files with 198 additions and 14 deletions

View File

@@ -403,6 +403,12 @@ describe('global appearance tokens', () => {
expect(appSource).toContain('fontFamilyCode: resolvedMonoFontFamily');
expect(appSource).toContain("t('app.theme.data_table.font_size')");
expect(appSource).toContain("t('app.theme.data_table.sidebar_tree_font_size')");
expect(appSource).toContain("const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action')");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_data')");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_design')");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action_hint')");
expect(appSource).toContain("setAppearance({ tableDoubleClickAction: value as 'open-data' | 'open-design' })");
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies, t)');
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies, t)');
expect(appSource).toContain('ListInstalledFontFamilies()');

View File

@@ -249,6 +249,7 @@ function App() {
const effectiveSidebarTreeFontSize = sidebarTreeFontSizeFollowsGlobal
? effectiveFontSize
: (sanitizeSidebarTreeFontSize(appearance.sidebarTreeFontSize) ?? effectiveFontSize);
const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';
const tabDisplaySettings = useMemo(
() => sanitizeTabDisplaySettings(appearance.tabDisplay),
[appearance.tabDisplay],
@@ -4789,6 +4790,21 @@ function App() {
onChange={(checked) => setAppearance({ showDataTableVerticalBorders: checked })}
/>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.data_table.table_double_click_action')}</div>
<Segmented
block
options={[
{ label: t('app.theme.data_table.table_double_click_action.open_data'), value: 'open-data' },
{ label: t('app.theme.data_table.table_double_click_action.open_design'), value: 'open-design' },
]}
value={tableDoubleClickAction}
onChange={(value) => setAppearance({ tableDoubleClickAction: value as 'open-data' | 'open-design' })}
/>
<div style={{ ...utilityMutedTextStyle, marginTop: 8 }}>
{t('app.theme.data_table.table_double_click_action_hint')}
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.data_table.density')}</div>
<Segmented

View File

@@ -323,6 +323,17 @@ describe('Sidebar locate toolbar', () => {
expect(source).toContain('if (shouldLoadSidebarNodeOnExpand(node))');
});
it('uses the appearance preference to switch table double-click to the embedded object designer', () => {
const source = readSidebarSource();
expect(source).toContain("const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';");
expect(source).toContain("type: 'table',");
expect(source).toContain("initialViewMode: tableDoubleClickAction === 'open-design' ? 'fields' : undefined");
expect(source).toContain("initialViewModeRequestId: tableDoubleClickAction === 'open-design' ? String(Date.now()) : undefined");
expect(source).not.toContain("if (tableDoubleClickAction === 'open-design') {\n openDesign(node, 'columns', false);");
expect(source).toContain('recordTableAccess(id, dbName, tableName);');
});
it('parses v2 command search prefixes into real search modes', () => {
expect(parseV2CommandSearchQuery('@ payment_order')).toMatchObject({
mode: 'object',
@@ -1087,8 +1098,8 @@ describe('Sidebar locate toolbar', () => {
expect(source).not.toContain("overflowX: isV2Ui ? 'auto' : 'hidden'");
expect(source).toContain('scrollWidth={isV2Ui ? v2TreeHorizontalScrollWidth : undefined}');
expect(utilsSource).toContain('export const V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE = 32;');
expect(source).toContain('const effectiveTreeHeight = isV2Ui && v2TreeHorizontalScrollWidth');
expect(source).toContain('treeHeight - V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE');
expect(source).toContain('const effectiveTreeHeight = treeHeight;');
expect(source).not.toContain('treeHeight - V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE');
expect(source).toContain('height={effectiveTreeHeight}');
expect(source).toContain('treeData={isV2Ui ? v2VisibleTreeData : displayTreeData}');
expect(source).not.toContain('__v2-tree-horizontal-scroll-spacer__');
@@ -1096,14 +1107,15 @@ describe('Sidebar locate toolbar', () => {
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \{[^}]*--gn-v2-tree-horizontal-scroll-reserve: 32px;[^}]*overflow: hidden !important;/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.sidebar-tree-scroll-content \{[^}]*display: flex;[^}]*height: 100%;[^}]*padding: 4px 0 0;/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree \{[^}]*flex: 1 1 auto;[^}]*width: 100%;[^}]*min-width: 0;[^}]*height: 100%;/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list \{[^}]*height: calc\(100% - var\(--gn-v2-tree-horizontal-scroll-reserve\)\);[^}]*min-height: 0;[^}]*box-sizing: border-box;/s);
expect(css).not.toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list \{[^}]*height: 100%;/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list \{[^}]*position: relative;[^}]*height: 100%;[^}]*min-height: 0;[^}]*box-sizing: border-box;[^}]*padding-bottom: var\(--gn-v2-tree-horizontal-scroll-reserve\);/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-holder-inner \{[^}]*width: 100%;[^}]*min-width: 100%;/s);
expect(css).not.toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-holder-inner \{[^}]*width: max-content;/s);
expect(css).not.toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list \{[^}]*position: static !important;/s);
expect(css).not.toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-holder \{[^}]*calc\(100% - var\(--gn-v2-tree-horizontal-scroll-reserve\)\)/s);
expect(css).not.toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-holder \{[^}]*overflow-x: auto !important;/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-scrollbar-horizontal \{[^}]*height: 12px !important;[^}]*bottom: calc\(\(var\(--gn-v2-tree-horizontal-scroll-reserve\) - 12px\) \* -1\) !important;/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-holder \{[^}]*padding-bottom: var\(--gn-v2-tree-horizontal-scroll-reserve\);/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-scrollbar-horizontal \{[^}]*height: 12px !important;[^}]*bottom: calc\(\(var\(--gn-v2-tree-horizontal-scroll-reserve\) - 12px\) \/ 2\) !important;/s);
expect(css).not.toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-scrollbar-horizontal \{[^}]*bottom: calc\(\(var\(--gn-v2-tree-horizontal-scroll-reserve\) - 12px\) \* -1\) !important;/s);
expect(css).not.toContain('.gn-v2-tree-horizontal-scroll-spacer');
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-scrollbar-horizontal \.ant-tree-list-scrollbar-thumb \{[^}]*height: 8px !important;/s);
const treeContentWrapperCss = readCssRuleBlock(css, 'body[data-ui-version="v2"] .gn-v2-explorer-tree-shell .ant-tree-node-content-wrapper');

View File

@@ -493,6 +493,7 @@ const Sidebar: React.FC<{
const v2UseLegacySidebarFilter = isV2Ui && v2SidebarSearchMode === 'filter';
const v2CommandSearchPersistentFilterEnabled = appearance.v2CommandSearchPersistentFilterEnabled === true;
const v2PersistedSidebarFilter = appearance.v2SidebarPersistedFilter ?? '';
const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';
const [searchValue, setSearchValue] = useState(v2PersistedSidebarFilter);
const deferredSearchValue = useDeferredValue(searchValue);
const [searchScopes, setSearchScopes] = useState<SearchScope[]>(['smart']);
@@ -1561,6 +1562,8 @@ const Sidebar: React.FC<{
connectionId: id,
dbName,
tableName,
initialViewMode: tableDoubleClickAction === 'open-design' ? 'fields' : undefined,
initialViewModeRequestId: tableDoubleClickAction === 'open-design' ? String(Date.now()) : undefined,
objectType: 'table',
});
return;

View File

@@ -6,7 +6,13 @@ import TableOverview from './TableOverview';
const storeState = vi.hoisted(() => ({
theme: 'light',
appearance: { uiVersion: 'legacy' as const },
appearance: {
uiVersion: 'legacy',
tableDoubleClickAction: 'open-data',
} as {
uiVersion: 'legacy' | 'v2';
tableDoubleClickAction: 'open-data' | 'open-design';
},
connections: [
{
id: 'conn-1',
@@ -56,6 +62,12 @@ vi.mock('../utils/autoFetchVisibility', () => ({
vi.mock('../utils/connectionRpcConfig', () => ({
buildRpcConnectionConfig: (config: unknown) => config,
}));
vi.mock('./ExportProgressModal', () => ({
useExportProgressDialog: () => ({
exportProgressModal: null,
runExportWithProgress: vi.fn(),
}),
}));
vi.mock('./V2TableContextMenu', () => ({
V2TableContextMenuView: () => null,
}));
@@ -90,6 +102,8 @@ vi.mock('antd', () => {
const Tooltip = ({ children }: any) => <div>{children}</div>;
const Modal: any = ({ children }: any) => <div>{children}</div>;
Modal.confirm = vi.fn();
const Text = ({ children }: any) => <span>{children}</span>;
const Paragraph = ({ children }: any) => <p>{children}</p>;
return {
Button,
Dropdown,
@@ -98,6 +112,7 @@ vi.mock('antd', () => {
Modal,
Spin,
Tooltip,
Typography: { Text, Paragraph },
message: messageApi,
};
});
@@ -126,6 +141,7 @@ const collectText = (node: any): string => {
describe('TableOverview tdengine compatibility', () => {
beforeEach(() => {
vi.clearAllMocks();
storeState.appearance = { uiVersion: 'legacy', tableDoubleClickAction: 'open-data' };
backendApp.DBGetTables.mockResolvedValue({
success: true,
data: [
@@ -159,4 +175,78 @@ describe('TableOverview tdengine compatibility', () => {
expect(renderedText).toContain('meters');
expect(renderedText).toContain('d001');
});
it('uses the table default open behavior for v2 card double-clicks', async () => {
storeState.appearance = { uiVersion: 'v2', tableDoubleClickAction: 'open-design' };
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<TableOverview tab={{
id: 'tab-1',
title: '表概览 - metrics',
type: 'table-overview',
connectionId: 'conn-1',
dbName: 'metrics',
} as any} />);
});
await flushPromises();
const card = renderer!.root.findAll((node) => node.props.className === 'gn-v2-table-card')[0];
expect(card).toBeTruthy();
await act(async () => {
card.props.onDoubleClick();
});
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
tableName: 'd001',
initialViewMode: 'fields',
initialViewModeRequestId: expect.any(String),
}));
expect(storeState.addTab).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'design',
tableName: 'd001',
}));
});
it('uses the table default open behavior for list double-clicks', async () => {
storeState.appearance = { uiVersion: 'v2', tableDoubleClickAction: 'open-design' };
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<TableOverview tab={{
id: 'tab-1',
title: '表概览 - metrics',
type: 'table-overview',
connectionId: 'conn-1',
dbName: 'metrics',
} as any} />);
});
await flushPromises();
const viewModeActions = renderer!.root.findAll((node) => (
typeof node.props.onClick === 'function' && node.props.style?.padding === '3px 7px'
));
expect(viewModeActions.length).toBeGreaterThanOrEqual(2);
await act(async () => {
viewModeActions[1].props.onClick();
});
const listRow = renderer!.root.findAll((node) => node.props.className === 'gn-v2-table-row')[0];
expect(listRow).toBeTruthy();
await act(async () => {
listRow.props.onDoubleClick();
});
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
tableName: 'd001',
initialViewMode: 'fields',
initialViewModeRequestId: expect.any(String),
}));
expect(storeState.addTab).not.toHaveBeenCalledWith(expect.objectContaining({
type: 'design',
tableName: 'd001',
}));
});
});

View File

@@ -259,6 +259,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const setSidebarTablePinned = useStore(state => state.setSidebarTablePinned);
const darkMode = theme === 'dark';
const isV2Ui = appearance.uiVersion === 'v2';
const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
const [tables, setTables] = useState<TableStatRow[]>([]);
@@ -464,6 +465,30 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
});
}, [connection, tab.dbName, addTab, setActiveContext, supportsDesignWrite, t]);
const openTableObjectDesigner = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `${connection.id}-${tab.dbName}-${tableName}`,
title: tableName,
type: 'table',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
initialViewMode: 'fields',
initialViewModeRequestId: String(Date.now()),
objectType: 'table',
});
}, [connection, tab.dbName, addTab, setActiveContext]);
const openTableByDefaultAction = useCallback((tableName: string) => {
if (tableDoubleClickAction === 'open-design') {
openTableObjectDesigner(tableName);
return;
}
openTable(tableName);
}, [openTable, openTableObjectDesigner, tableDoubleClickAction]);
const openTableDdl = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
@@ -990,7 +1015,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const renderCardTableContent = (table: TableStatRow) => (
<div
className={isV2Ui ? 'gn-v2-table-card' : undefined}
onDoubleClick={() => openTable(table.name)}
onDoubleClick={() => openTableByDefaultAction(table.name)}
onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, table) : undefined}
style={{
background: cardBg,
@@ -1059,7 +1084,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const content = (
<div
className={isV2Ui ? 'gn-v2-table-row' : undefined}
onDoubleClick={() => openTable(table.name)}
onDoubleClick={() => openTableByDefaultAction(table.name)}
onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, table) : undefined}
style={{
position: 'relative',

View File

@@ -32,7 +32,6 @@ import {
import type { SearchScope } from '../sidebarCoreUtils';
import {
buildV2CommandSearchTreeIndex,
V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
@@ -612,9 +611,7 @@ export const useSidebarSearchModel = ({
() => estimateV2TreeHorizontalScrollWidth(v2VisibleTreeData, treeViewportWidth),
[treeViewportWidth, v2VisibleTreeData],
);
const effectiveTreeHeight = isV2Ui && v2TreeHorizontalScrollWidth
? Math.max(1, treeHeight - V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE)
: treeHeight;
const effectiveTreeHeight = treeHeight;
const v2TreeMetrics = useMemo(() => {
const databaseTableCounts = new Map<React.Key, number>();
const objectGroupCounts = new Map<React.Key, number>();

View File

@@ -69,6 +69,7 @@ describe('store appearance persistence', () => {
expect(appearance.opacity).toBe(0.75);
expect(appearance.blur).toBe(6);
expect(appearance.useNativeMacWindowControls).toBe(true);
expect(appearance.tableDoubleClickAction).toBe('open-data');
expect(appearance.v2SidebarSearchMode).toBe('command');
expect(appearance.v2CommandSearchPersistentFilterEnabled).toBe(false);
expect(appearance.v2SidebarPersistedFilter).toBe('');
@@ -93,11 +94,13 @@ describe('store appearance persistence', () => {
useStore.getState().setAppearance({
showDataTableVerticalBorders: true,
dataTableDensity: 'compact',
tableDoubleClickAction: 'open-design',
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.appearance.showDataTableVerticalBorders).toBe(true);
expect(persisted.state.appearance.dataTableDensity).toBe('compact');
expect(persisted.state.appearance.tableDoubleClickAction).toBe('open-design');
vi.resetModules();
const reloaded = await importStore();
@@ -105,6 +108,21 @@ describe('store appearance persistence', () => {
expect(appearance.showDataTableVerticalBorders).toBe(true);
expect(appearance.dataTableDensity).toBe('compact');
expect(appearance.tableDoubleClickAction).toBe('open-design');
});
it('sanitizes invalid table double-click appearance settings', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
appearance: {
tableDoubleClickAction: 'open-random',
},
},
version: 10,
}));
const { useStore } = await importStore();
expect(useStore.getState().appearance.tableDoubleClickAction).toBe('open-data');
});
it('persists language preference and sanitizes unsupported persisted values', async () => {

View File

@@ -79,12 +79,15 @@ import {
resolveConnectionProtectionConfig,
} from "./utils/connectionReadOnly";
export type TableDoubleClickAction = "open-data" | "open-design";
export interface AppearanceSettings extends DataGridDisplaySettings {
uiVersion: "legacy" | "v2";
enabled: boolean;
opacity: number;
blur: number;
useNativeMacWindowControls: boolean;
tableDoubleClickAction: TableDoubleClickAction;
v2SidebarSearchMode: "command" | "filter";
v2CommandSearchPersistentFilterEnabled: boolean;
v2SidebarPersistedFilter: string;
@@ -100,6 +103,7 @@ export const DEFAULT_APPEARANCE: AppearanceSettings = {
opacity: 1.0,
blur: 0,
useNativeMacWindowControls: false,
tableDoubleClickAction: "open-data",
v2SidebarSearchMode: "command",
v2CommandSearchPersistentFilterEnabled: false,
v2SidebarPersistedFilter: "",
@@ -126,6 +130,12 @@ const sanitizeV2SidebarSearchMode = (
return value === "filter" ? "filter" : DEFAULT_APPEARANCE.v2SidebarSearchMode;
};
const sanitizeTableDoubleClickAction = (
value: unknown,
): TableDoubleClickAction => {
return value === "open-design" ? "open-design" : DEFAULT_APPEARANCE.tableDoubleClickAction;
};
const sanitizeV2SidebarPersistedFilter = (value: unknown): string => {
if (typeof value !== "string") {
return DEFAULT_APPEARANCE.v2SidebarPersistedFilter;
@@ -2062,6 +2072,9 @@ const sanitizeAppearance = (
typeof appearance.useNativeMacWindowControls === "boolean"
? appearance.useNativeMacWindowControls
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
tableDoubleClickAction: sanitizeTableDoubleClickAction(
appearance.tableDoubleClickAction,
),
v2SidebarSearchMode: sanitizeV2SidebarSearchMode(
appearance.v2SidebarSearchMode,
),

View File

@@ -2608,12 +2608,16 @@ body[data-ui-version="v2"] .gn-v2-explorer-tree-shell .ant-tree {
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell .ant-tree-list {
height: calc(100% - var(--gn-v2-tree-horizontal-scroll-reserve));
position: relative;
height: 100%;
min-height: 0;
box-sizing: border-box;
padding-bottom: var(--gn-v2-tree-horizontal-scroll-reserve);
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell .ant-tree-list-holder {
box-sizing: border-box;
padding-bottom: var(--gn-v2-tree-horizontal-scroll-reserve);
scrollbar-width: thin;
}
@@ -2621,7 +2625,7 @@ body[data-ui-version="v2"] .gn-v2-explorer-tree-shell .ant-tree-list-scrollbar-h
height: 12px !important;
left: 8px !important;
right: 8px !important;
bottom: calc((var(--gn-v2-tree-horizontal-scroll-reserve) - 12px) * -1) !important;
bottom: calc((var(--gn-v2-tree-horizontal-scroll-reserve) - 12px) / 2) !important;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell .ant-tree-list-scrollbar-horizontal .ant-tree-list-scrollbar-thumb {