🐛 fix(query-editor): 优化工具栏长名称提示与片段弹窗布局

- SQL 编辑器连接和数据库选择器支持悬浮延迟显示完整名称
- SQL 片段管理嵌入工具中心时隐藏重复标题
- 缩小片段操作按钮并拆分滚动区域,避免底部内容被遮挡
- 补充工具栏和片段弹窗源码级回归断言
This commit is contained in:
Syngnat
2026-06-25 18:42:11 +08:00
parent aebe9bab54
commit 8a56367d02
7 changed files with 237 additions and 40 deletions

View File

@@ -7478,6 +7478,10 @@ describe('QueryEditor external SQL save', () => {
expect(toolbarSource).toContain('gn-v2-query-toolbar-actions');
expect(toolbarSource).toContain('gn-v2-query-toolbar-connection-select');
expect(toolbarSource).toContain('gn-v2-query-toolbar-database-select');
expect(toolbarSource).toContain('FULL_NAME_TOOLTIP_DELAY_SECONDS = 1');
expect(toolbarSource).toContain('mouseEnterDelay={FULL_NAME_TOOLTIP_DELAY_SECONDS}');
expect(toolbarSource).toContain('optionRender={(option) => renderFullNameSelectTooltip(option.data.fullName)}');
expect(toolbarSource).toContain('labelRender={(option) => renderFullNameSelectTooltip(option.label ?? option.value)}');
expect(toolbarSource).toContain('gn-v2-query-toolbar-max-rows-select');
expect(toolbarSource).toContain('QueryEditorTransactionSettings');
expect(transactionSettingsSource).toContain('gn-v2-query-toolbar-transaction-mode-select');
@@ -7556,10 +7560,12 @@ describe('QueryEditor external SQL save', () => {
expect(modalSource).toContain('data-sql-snippet-action-row="true"');
expect(modalSource).toContain('data-sql-snippet-content-region="true"');
expect(modalSource).toContain('data-sql-snippet-editor-scroll-region="true"');
expect(modalSource).toContain("maxHeight: snippetModalBodyMaxHeight");
expect(modalSource).toContain('maxHeight: embedded ? snippetModalEmbeddedBodyMaxHeight : snippetModalBodyMaxHeight');
expect(modalSource).toContain('data-sql-snippet-syntax-reference-scroll-region="true"');
expect(modalSource).toContain('data-sql-snippet-editor-panel-scroll-region="true"');
expect(modalSource).toContain("flex: '0 0 auto'");
expect(modalSource).toContain("size=\"large\"");
expect(modalSource).toContain('minWidth: 96');
expect(modalSource).toContain("size=\"middle\"");
expect(modalSource).toContain('minWidth: 84');
expect(modalSource).toContain('syntaxHelp');
expect(modalSource).toContain("t('snippet_settings.syntax_reference.label')");
expect(source).toContain('s.syntaxHelp || s.description || s.body');

View File

@@ -2712,6 +2712,10 @@ describe('QueryEditor external SQL save', () => {
expect(toolbarSource).toContain('gn-v2-query-toolbar-actions');
expect(toolbarSource).toContain('gn-v2-query-toolbar-connection-select');
expect(toolbarSource).toContain('gn-v2-query-toolbar-database-select');
expect(toolbarSource).toContain('FULL_NAME_TOOLTIP_DELAY_SECONDS = 1');
expect(toolbarSource).toContain('mouseEnterDelay={FULL_NAME_TOOLTIP_DELAY_SECONDS}');
expect(toolbarSource).toContain('optionRender={(option) => renderFullNameSelectTooltip(option.data.fullName)}');
expect(toolbarSource).toContain('labelRender={(option) => renderFullNameSelectTooltip(option.label ?? option.value)}');
expect(toolbarSource).toContain('gn-v2-query-toolbar-max-rows-select');
expect(toolbarSource).toContain('QueryEditorTransactionSettings');
expect(transactionSettingsSource).toContain('gn-v2-query-toolbar-transaction-mode-select');
@@ -2789,10 +2793,12 @@ describe('QueryEditor external SQL save', () => {
expect(modalSource).toContain('data-sql-snippet-action-row="true"');
expect(modalSource).toContain('data-sql-snippet-content-region="true"');
expect(modalSource).toContain('data-sql-snippet-editor-scroll-region="true"');
expect(modalSource).toContain("maxHeight: snippetModalBodyMaxHeight");
expect(modalSource).toContain('maxHeight: embedded ? snippetModalEmbeddedBodyMaxHeight : snippetModalBodyMaxHeight');
expect(modalSource).toContain('data-sql-snippet-syntax-reference-scroll-region="true"');
expect(modalSource).toContain('data-sql-snippet-editor-panel-scroll-region="true"');
expect(modalSource).toContain("flex: '0 0 auto'");
expect(modalSource).toContain("size=\"large\"");
expect(modalSource).toContain('minWidth: 96');
expect(modalSource).toContain("size=\"middle\"");
expect(modalSource).toContain('minWidth: 84');
expect(modalSource).toContain('syntaxHelp');
expect(modalSource).toContain("t('snippet_settings.syntax_reference.label')");
expect(source).toContain('s.syntaxHelp || s.description || s.body');

View File

@@ -79,4 +79,29 @@ describe('QueryEditorToolbar layout', () => {
expect(transactionDelayCss).toContain('width: 104px !important;');
expect(transactionDelayCss).toContain('flex: 0 0 104px !important;');
});
it('shows delayed full-name tooltips for truncated connection and database selectors', () => {
const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8');
const css = readV2ThemeCss();
const connectionSelectSource = toolbarSource.slice(
toolbarSource.indexOf('gn-v2-query-toolbar-connection-select'),
toolbarSource.indexOf('gn-v2-query-toolbar-database-select'),
);
const databaseSelectSource = toolbarSource.slice(
toolbarSource.indexOf('gn-v2-query-toolbar-database-select'),
toolbarSource.indexOf('gn-v2-query-toolbar-max-rows-select'),
);
expect(toolbarSource).toContain('FULL_NAME_TOOLTIP_DELAY_SECONDS = 1');
expect(toolbarSource).toContain('mouseEnterDelay={FULL_NAME_TOOLTIP_DELAY_SECONDS}');
expect(toolbarSource).toContain('renderFullNameSelectTooltip');
expect(toolbarSource).toContain('gn-query-toolbar-select-full-name');
expect(connectionSelectSource).toContain('optionRender={(option) => renderFullNameSelectTooltip(option.data.fullName)}');
expect(connectionSelectSource).toContain('labelRender={(option) => renderFullNameSelectTooltip(option.label ?? option.value)}');
expect(databaseSelectSource).toContain('optionRender={(option) => renderFullNameSelectTooltip(option.data.fullName)}');
expect(databaseSelectSource).toContain('labelRender={(option) => renderFullNameSelectTooltip(option.label ?? option.value)}');
expect(css).toContain('.gn-query-toolbar-select-full-name {');
expect(css).toContain('text-overflow: ellipsis;');
expect(css).toContain('white-space: nowrap;');
});
});

View File

@@ -55,6 +55,33 @@ type QueryEditorToolbarProps = {
onAIAction: (action: "generate" | "explain" | "optimize" | "schema") => void;
};
const FULL_NAME_TOOLTIP_DELAY_SECONDS = 1;
type FullNameSelectOption = {
label: string;
value: string;
fullName: string;
};
const renderFullNameSelectTooltip = (fullName: React.ReactNode) => {
const fullNameText = String(fullName ?? "");
return (
<Tooltip
title={fullNameText}
mouseEnterDelay={FULL_NAME_TOOLTIP_DELAY_SECONDS}
placement="topLeft"
>
<span
className="gn-query-toolbar-select-full-name"
aria-label={fullNameText}
>
{fullNameText}
</span>
</Tooltip>
);
};
const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
isV2Ui,
currentConnectionId,
@@ -89,6 +116,17 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
const baseMoreMenuItems = saveMoreMenuItems ?? [];
const connectionSelectOptions: FullNameSelectOption[] =
queryCapableConnections.map((connection) => ({
label: connection.name,
value: connection.id,
fullName: connection.name,
}));
const databaseSelectOptions: FullNameSelectOption[] = dbList.map((db) => ({
label: db,
value: db,
fullName: db,
}));
const toggleResultPanelShortcutLabel =
toggleQueryResultsPanelShortcutBinding.enabled &&
toggleQueryResultsPanelShortcutBinding.combo
@@ -172,10 +210,10 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
placeholder={t("query_editor.placeholder.connection")}
value={currentConnectionId}
onChange={onConnectionChange}
options={queryCapableConnections.map((c) => ({
label: c.name,
value: c.id,
}))}
options={connectionSelectOptions}
optionFilterProp="label"
optionRender={(option) => renderFullNameSelectTooltip(option.data.fullName)}
labelRender={(option) => renderFullNameSelectTooltip(option.label ?? option.value)}
showSearch
/>
<Select
@@ -188,7 +226,10 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
placeholder={t("query_editor.placeholder.database")}
value={currentDb}
onChange={onDatabaseChange}
options={dbList.map((db) => ({ label: db, value: db }))}
options={databaseSelectOptions}
optionFilterProp="label"
optionRender={(option) => renderFullNameSelectTooltip(option.data.fullName)}
labelRender={(option) => renderFullNameSelectTooltip(option.label ?? option.value)}
showSearch
/>
<Tooltip title={t("query_editor.max_rows.tooltip")}>

View File

@@ -225,7 +225,7 @@ const overlayTheme: OverlayWorkbenchTheme = {
divider: '#eee',
};
const renderModal = async () => {
const renderModal = async (props: Partial<React.ComponentProps<typeof SnippetSettingsModal>> = {}) => {
let renderer: ReturnType<typeof create>;
await act(async () => {
@@ -240,6 +240,7 @@ const renderModal = async () => {
onClose={() => undefined}
darkMode={false}
overlayTheme={overlayTheme}
{...props}
/>
</I18nProvider>,
);
@@ -322,11 +323,30 @@ describe('SnippetSettingsModal i18n', () => {
it('keeps snippet editor content scrollable without clipping the action row', () => {
expect(source).toContain("const snippetModalBodyMaxHeight = 'calc(100vh - 128px)';");
expect(source).toContain("maxHeight: snippetModalBodyMaxHeight");
expect(source).toContain("const snippetModalEmbeddedBodyMaxHeight = '100%';");
expect(source).toContain("const snippetSyntaxReferenceMaxHeight = 'min(220px, 32vh)';");
expect(source).toContain('maxHeight: embedded ? snippetModalEmbeddedBodyMaxHeight : snippetModalBodyMaxHeight');
expect(source).toContain('data-sql-snippet-syntax-reference-scroll-region="true"');
expect(source).toContain('data-sql-snippet-editor-panel-scroll-region="true"');
expect(source).toContain('data-sql-snippet-content-region="true"');
expect(source).toContain('data-sql-snippet-editor-scroll-region="true"');
expect(source).toContain("overflowY: 'auto'");
expect(source).toContain("flex: '0 0 auto'");
expect(source).toContain('height: 220');
expect(source).toContain('maxHeight: 260');
});
it('lets the tool center provide the title when embedded', async () => {
const renderer = await renderModal({ embedded: true });
const root = renderer.root;
expect(() => root.findByProps({ className: 'gn-embedded-modal-header' })).toThrow();
expect(getJsonText(renderer.toJSON())).toContain('Select a snippet on the left to edit, or click "New Snippet"');
const standaloneRenderer = await renderModal();
const standaloneText = getJsonText(standaloneRenderer.toJSON());
expect(standaloneText).toContain('Snippet Management');
expect(standaloneText).toContain('Manage SQL snippets and prefix completion.');
});
it('keeps the shell and feedback keys available in every locale', () => {
@@ -384,7 +404,9 @@ describe('SnippetSettingsModal i18n', () => {
});
const contentRegion = renderer.root.findByProps({ 'data-sql-snippet-content-region': 'true' });
const editorPanelScrollRegion = renderer.root.findByProps({ 'data-sql-snippet-editor-panel-scroll-region': 'true' });
const editorScrollRegion = renderer.root.findByProps({ 'data-sql-snippet-editor-scroll-region': 'true' });
const syntaxReferenceScrollRegion = renderer.root.findByProps({ 'data-sql-snippet-syntax-reference-scroll-region': 'true' });
const actionRow = renderer.root.findByProps({ 'data-sql-snippet-action-row': 'true' });
expect(contentRegion.props.style).toMatchObject({
@@ -392,14 +414,83 @@ describe('SnippetSettingsModal i18n', () => {
minHeight: 0,
overflow: 'hidden',
});
expect(editorScrollRegion.props.style).toMatchObject({
flex: 1,
expect(editorPanelScrollRegion.props.style).toMatchObject({
height: '100%',
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
overscrollBehavior: 'contain',
});
expect(editorScrollRegion.props.style).toMatchObject({
flex: '0 0 auto',
minHeight: 0,
marginTop: 10,
});
expect(syntaxReferenceScrollRegion.props.style).toMatchObject({
maxHeight: 'min(220px, 32vh)',
overflowY: 'auto',
overflowX: 'hidden',
overscrollBehavior: 'contain',
});
expect(actionRow.props.style).toMatchObject({
flex: '0 0 auto',
gap: 10,
justifyContent: 'flex-end',
paddingTop: 8,
marginTop: 8,
});
});
it('uses the full embedded body height and keeps the action row outside the scrollable content', async () => {
const renderer = await renderModal({ embedded: true });
const newButton = renderer.root.findAll((node: any) => node.type === 'button' && getText(node).includes('New Snippet'))[0];
await act(async () => {
newButton.props.onClick();
});
const embeddedBody = renderer.root.findByProps({ className: 'gn-embedded-modal-body' });
const contentRegion = renderer.root.findByProps({ 'data-sql-snippet-content-region': 'true' });
const actionRow = renderer.root.findByProps({ 'data-sql-snippet-action-row': 'true' });
expect(embeddedBody.props.style).toMatchObject({
height: '100%',
maxHeight: '100%',
minHeight: 0,
overflow: 'hidden',
});
expect(contentRegion.props.style).toMatchObject({
flex: '1 1 0',
minHeight: 0,
overflow: 'hidden',
});
expect(actionRow.props.style).toMatchObject({
flex: '0 0 auto',
gap: 10,
paddingTop: 8,
marginTop: 8,
});
});
it('uses compact action buttons so the footer does not consume editor height', async () => {
const renderer = await renderModal({ embedded: true, onBack: () => undefined });
const newButton = renderer.root.findAll((node: any) => node.type === 'button' && getText(node).includes('New Snippet'))[0];
await act(async () => {
newButton.props.onClick();
});
const saveButton = renderer.root.findAll((node: any) => node.type === 'button' && getText(node).includes('Save'))[0];
const closeButton = renderer.root.findAll((node: any) => node.type === 'button' && getText(node).includes('Close'))[0];
const backButton = renderer.root.findAll((node: any) => node.type === 'button' && getText(node).includes('Back'))[0];
expect(saveButton.props.size).toBe('middle');
expect(saveButton.props.style).toMatchObject({ minWidth: 84 });
expect(closeButton.props.size).toBe('middle');
expect(closeButton.props.style).toMatchObject({ minWidth: 84 });
expect(backButton.props.size).toBe('middle');
expect(backButton.props.style).toMatchObject({ minWidth: 104 });
});
});

View File

@@ -78,6 +78,8 @@ export default function SnippetSettingsModal({
const selectedBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
const newSnippetAction = t('snippet_settings.action.new');
const snippetModalBodyMaxHeight = 'calc(100vh - 128px)';
const snippetModalEmbeddedBodyMaxHeight = '100%';
const snippetSyntaxReferenceMaxHeight = 'min(220px, 32vh)';
const sortedSnippets = useMemo(
() => [...sqlSnippets].sort((a, b) => a.prefix.localeCompare(b.prefix)),
@@ -193,7 +195,20 @@ export default function SnippetSettingsModal({
key: 'syntax',
label: t('snippet_settings.syntax_reference.label'),
children: (
<div style={{ fontSize: 12, lineHeight: 1.8, color: mutedColor, fontFamily: 'var(--gn-font-mono)' }}>
<div
data-sql-snippet-syntax-reference-scroll-region="true"
style={{
maxHeight: snippetSyntaxReferenceMaxHeight,
overflowY: 'auto',
overflowX: 'hidden',
overscrollBehavior: 'contain',
paddingRight: 6,
fontSize: 12,
lineHeight: 1.8,
color: mutedColor,
fontFamily: 'var(--gn-font-mono)',
}}
>
<div>{t('snippet_settings.syntax_reference.first_tabstop')}</div>
<div>{t('snippet_settings.syntax_reference.second_tabstop')}</div>
<div>{t('snippet_settings.syntax_reference.final_cursor')}</div>
@@ -211,14 +226,14 @@ export default function SnippetSettingsModal({
),
},
],
[draft.syntaxHelp, mutedColor, t, textColor],
[draft.syntaxHelp, mutedColor, snippetSyntaxReferenceMaxHeight, t, textColor],
);
const showEditor = isCreating || selectedSnippet;
return (
<Modal
title={
title={embedded ? null : (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, minWidth: 0 }}>
<div
style={{
@@ -241,9 +256,10 @@ export default function SnippetSettingsModal({
</div>
</div>
</div>
}
)}
open={open}
embedded={embedded}
closable={embedded ? false : undefined}
onCancel={onClose}
width={820}
styles={{
@@ -254,7 +270,8 @@ export default function SnippetSettingsModal({
paddingBottom: 0,
display: 'flex',
flexDirection: 'column',
maxHeight: snippetModalBodyMaxHeight,
height: embedded ? '100%' : undefined,
maxHeight: embedded ? snippetModalEmbeddedBodyMaxHeight : snippetModalBodyMaxHeight,
minHeight: 0,
overflow: 'hidden',
},
@@ -266,7 +283,7 @@ export default function SnippetSettingsModal({
style={{
display: 'flex',
gap: 16,
flex: '1 1 420px',
flex: embedded ? '1 1 0' : '1 1 420px',
minHeight: 0,
overflow: 'hidden',
}}
@@ -351,20 +368,23 @@ export default function SnippetSettingsModal({
</div>
{/* Right: editor */}
<div style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex' }}>
<div style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', overflow: 'hidden' }}>
{showEditor ? (
<div
style={{
...panelStyle,
display: 'flex',
flexDirection: 'column',
gap: 12,
height: '100%',
minHeight: 0,
overflow: 'hidden',
overflowY: 'auto',
overflowX: 'hidden',
overscrollBehavior: 'contain',
paddingRight: 12,
}}
data-sql-snippet-editor-panel-scroll-region="true"
>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ display: 'flex', gap: 12, flex: '0 0 auto' }}>
<div style={{ flex: 0.4 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>{t('snippet_settings.field.prefix.label')}</div>
<Input
@@ -389,7 +409,7 @@ export default function SnippetSettingsModal({
</div>
</div>
<div>
<div style={{ flex: '0 0 auto', marginTop: 10 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>{t('snippet_settings.field.description.label')}</div>
<Input
value={draft.description || ''}
@@ -403,12 +423,11 @@ export default function SnippetSettingsModal({
<div
data-sql-snippet-editor-scroll-region="true"
style={{
flex: 1,
flex: '0 0 auto',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflowY: 'auto',
paddingRight: 4,
marginTop: 10,
}}
>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>{t('snippet_settings.field.body.label')}</div>
@@ -417,8 +436,9 @@ export default function SnippetSettingsModal({
onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
placeholder={'SELECT ${1:columns} FROM ${2:table_name}$0;'}
style={{
flex: 1,
minHeight: 120,
height: 220,
minHeight: 160,
maxHeight: 260,
fontFamily: 'var(--gn-font-mono)',
fontSize: 13,
resize: 'none',
@@ -428,7 +448,7 @@ export default function SnippetSettingsModal({
size="small"
defaultActiveKey={['snippet-help']}
items={syntaxHelpItems}
style={{ marginTop: 8, background: 'transparent' }}
style={{ marginTop: 8, background: 'transparent', flex: '0 0 auto' }}
/>
</div>
@@ -454,11 +474,11 @@ export default function SnippetSettingsModal({
style={{
display: 'flex',
flex: '0 0 auto',
gap: 12,
gap: 10,
justifyContent: 'flex-end',
alignItems: 'center',
paddingTop: 12,
marginTop: 12,
paddingTop: 8,
marginTop: 8,
borderTop: overlayTheme.sectionBorder,
}}
>
@@ -468,7 +488,7 @@ export default function SnippetSettingsModal({
description={t('snippet_settings.confirm.reset.description')}
onConfirm={() => handleReset(draft.id)}
>
<Button icon={<UndoOutlined />} size="large" style={{ minWidth: 118 }}>
<Button icon={<UndoOutlined />} size="middle" style={{ minWidth: 104 }}>
{t('snippet_settings.action.reset')}
</Button>
</Popconfirm>
@@ -479,21 +499,21 @@ export default function SnippetSettingsModal({
description={t('snippet_settings.confirm.delete.description')}
onConfirm={() => handleDelete(draft.id)}
>
<Button danger icon={<DeleteOutlined />} size="large" style={{ minWidth: 96 }}>
<Button danger icon={<DeleteOutlined />} size="middle" style={{ minWidth: 84 }}>
{t('snippet_settings.action.delete')}
</Button>
</Popconfirm>
)}
{showEditor && (
<Button type="primary" icon={<SaveOutlined />} size="large" style={{ minWidth: 96 }} onClick={handleSave}>
<Button type="primary" icon={<SaveOutlined />} size="middle" style={{ minWidth: 84 }} onClick={handleSave}>
{t('snippet_settings.action.save')}
</Button>
)}
<Button size="large" style={{ minWidth: 96 }} onClick={onClose}>
<Button size="middle" style={{ minWidth: 84 }} onClick={onClose}>
{t('snippet_settings.action.close')}
</Button>
{onBack ? (
<Button size="large" style={{ minWidth: 124 }} onClick={onBack}>
<Button size="middle" style={{ minWidth: 104 }} onClick={onBack}>
{t('common.back_to_previous')}
</Button>
) : null}

View File

@@ -148,6 +148,14 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar-database-select {
max-width: 220px;
}
.gn-query-toolbar-select-full-name {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-max-rows-select {
width: 132px !important;
flex: 0 0 132px !important;