🐛 fix(query-editor): 将执行耗时固定到编辑器底部

- 将计时状态移至 Monaco 底部并在执行完成后保留耗时
- 按一秒和五秒边界显示闪电、兔子或乌龟速度图标
- 保持工具栏停止按钮紧凑并补齐计时生命周期与布局回归
This commit is contained in:
Syngnat
2026-07-29 20:36:27 +08:00
parent cb3a5f6b61
commit fb686a3d94
4 changed files with 193 additions and 52 deletions

View File

@@ -100,7 +100,11 @@ import {
} from '../utils/resultDiff/viewDataVerify';
import { SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS } from './QueryEditorTransactionSettings';
import QueryEditorTransactionToolbar from './QueryEditorTransactionToolbar';
import QueryEditorToolbar from './QueryEditorToolbar';
import QueryEditorToolbar, {
formatQueryExecutionElapsed,
resolveQueryExecutionSpeedIcon,
useQueryExecutionElapsed,
} from './QueryEditorToolbar';
import { useSqlEditorTransactionController } from './useSqlEditorTransactionController';
import {
type CompletionColumnMeta,
@@ -1323,6 +1327,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
resultSetsRef.current = resultSets;
activeResultKeyRef.current = activeResultKey;
const [loading, setLoading] = useState(false);
const [executionRunToken, setExecutionRunToken] = useState(0);
const executionElapsedMs = useQueryExecutionElapsed(loading, executionRunToken);
const executionElapsedText = formatQueryExecutionElapsed(executionElapsedMs);
const executionElapsedLabel = translate('query_editor.execution.elapsed', {
duration: executionElapsedText,
});
const executionSpeedIcon = resolveQueryExecutionSpeedIcon(executionElapsedMs);
const [executionError, setExecutionError] = useState<string>('');
const [, setCurrentQueryId] = useState<string>('');
const [isSqlSnippetPickerOpen, setIsSqlSnippetPickerOpen] = useState(false);
@@ -7008,6 +7019,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
clearQueryId();
}
const runSeq = ++runSeqRef.current;
setExecutionRunToken(runSeq);
setLoading(true);
setExecutionError('');
const runStartTime = Date.now();
@@ -9201,6 +9213,21 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
options={queryEditorMonacoOptions}
/>
</div>
<div className="gn-query-execution-statusbar">
<span
aria-label={executionElapsedLabel}
className="gn-query-execution-timer"
role="timer"
title={executionElapsedLabel}
>
<span aria-hidden="true" className="gn-query-execution-speed-icon">
{executionSpeedIcon}
</span>
<span className="gn-query-execution-elapsed">
{executionElapsedText}
</span>
</span>
</div>
</div>
{isResultPanelVisible && (

View File

@@ -1,8 +1,13 @@
import React from 'react';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';
import { readV2ThemeCss } from '../test/readV2ThemeCss';
import { formatQueryExecutionElapsed } from './QueryEditorToolbar';
import {
formatQueryExecutionElapsed,
resolveQueryExecutionSpeedIcon,
useQueryExecutionElapsed,
} from './QueryEditorToolbar';
describe('QueryEditorToolbar layout', () => {
it('keeps the v2 toolbar on a single scrollable row in small windows', () => {
@@ -42,22 +47,96 @@ describe('QueryEditorToolbar layout', () => {
expect(formatQueryExecutionElapsed(Number.NaN)).toBe('00:00.0');
});
it('keeps the live execution timer inside a fixed-width stop action', () => {
it('uses distinct speed icons at the one- and five-second boundaries', () => {
expect(resolveQueryExecutionSpeedIcon(0)).toBe('⚡');
expect(resolveQueryExecutionSpeedIcon(999)).toBe('⚡');
expect(resolveQueryExecutionSpeedIcon(1_000)).toBe('🐇');
expect(resolveQueryExecutionSpeedIcon(4_999)).toBe('🐇');
expect(resolveQueryExecutionSpeedIcon(5_000)).toBe('🐢');
});
it('keeps the completed duration until the next execution starts', () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
let elapsedMs = -1;
let renderer: ReactTestRenderer | null = null;
const Harness: React.FC<{ loading: boolean; runToken: number }> = ({ loading, runToken }) => {
elapsedMs = useQueryExecutionElapsed(loading, runToken);
return null;
};
try {
act(() => {
renderer = create(<Harness loading={false} runToken={0} />);
});
expect(elapsedMs).toBe(0);
act(() => {
renderer?.update(<Harness loading runToken={1} />);
});
act(() => {
vi.advanceTimersByTime(350);
});
expect(elapsedMs).toBe(300);
act(() => {
renderer?.update(<Harness loading runToken={2} />);
});
expect(elapsedMs).toBe(0);
act(() => {
vi.advanceTimersByTime(350);
});
expect(elapsedMs).toBe(300);
act(() => {
renderer?.update(<Harness loading={false} runToken={2} />);
});
expect(elapsedMs).toBe(350);
act(() => {
vi.advanceTimersByTime(1_000);
});
expect(elapsedMs).toBe(350);
act(() => {
renderer?.update(<Harness loading runToken={3} />);
});
expect(elapsedMs).toBe(0);
} finally {
act(() => {
renderer?.unmount();
});
vi.useRealTimers();
}
});
it('keeps live and completed execution time at the editor bottom-left', () => {
const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8');
const editorSource = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
const css = readV2ThemeCss();
const stopActionCss = css.slice(
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-stop-action.ant-btn {'),
css.indexOf('.gn-query-toolbar-execution-elapsed {'),
const statusbarCss = css.slice(
css.indexOf('.gn-query-execution-statusbar {'),
css.indexOf('.gn-query-execution-timer {'),
);
const elapsedCss = css.slice(
css.indexOf('.gn-query-toolbar-execution-elapsed {'),
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-menu-trigger {'),
css.indexOf('.gn-query-execution-elapsed {'),
css.indexOf('body[data-ui-version="v2"] .gn-v2-query-resizer {'),
);
expect(toolbarSource).toContain('window.setInterval(updateElapsed, QUERY_EXECUTION_TIMER_INTERVAL_MS)');
expect(toolbarSource).toContain('query_editor.execution.elapsed');
expect(stopActionCss).toContain('width: 128px !important;');
expect(stopActionCss).toContain('flex: 0 0 128px;');
expect(toolbarSource).toContain('globalThis.setInterval(updateElapsed, QUERY_EXECUTION_TIMER_INTERVAL_MS)');
expect(toolbarSource).toContain('startedAtRef.current = null');
expect(toolbarSource).not.toContain('gn-query-toolbar-execution-slot');
expect(editorSource).toContain('className="gn-query-execution-statusbar"');
expect(editorSource).toContain('className="gn-query-execution-timer"');
expect(editorSource).toContain('role="timer"');
expect(editorSource).toContain('query_editor.execution.elapsed');
const statusbarIndex = editorSource.indexOf('className="gn-query-execution-statusbar"');
expect(statusbarIndex).toBeGreaterThan(editorSource.indexOf('<Editor'));
expect(statusbarIndex).toBeLessThan(editorSource.indexOf('<QueryEditorResultsPanel', statusbarIndex));
expect(statusbarCss).toContain('flex: 0 0 22px;');
expect(statusbarCss).toContain('padding: 0 10px;');
expect(elapsedCss).toContain('min-width: 10ch;');
expect(elapsedCss).toContain('font-variant-numeric: tabular-nums;');
expect(elapsedCss).toContain('letter-spacing: 0;');

View File

@@ -87,6 +87,39 @@ export const formatQueryExecutionElapsed = (elapsedMs: number): string => {
: `${minutesText}:${secondsText}.${tenths}`;
};
export const resolveQueryExecutionSpeedIcon = (elapsedMs: number): "⚡" | "🐇" | "🐢" => {
const normalizedElapsedMs = Math.max(0, Number(elapsedMs) || 0);
if (normalizedElapsedMs < 1_000) return "⚡";
if (normalizedElapsedMs < 5_000) return "🐇";
return "🐢";
};
export const useQueryExecutionElapsed = (loading: boolean, executionRunToken = 0): number => {
const [elapsedMs, setElapsedMs] = React.useState(0);
const startedAtRef = React.useRef<number | null>(null);
React.useEffect(() => {
if (!loading) {
const startedAt = startedAtRef.current;
if (startedAt !== null) {
setElapsedMs(Date.now() - startedAt);
startedAtRef.current = null;
}
return;
}
const startedAt = Date.now();
startedAtRef.current = startedAt;
setElapsedMs(0);
const updateElapsed = () => setElapsedMs(Date.now() - startedAt);
updateElapsed();
const timer = globalThis.setInterval(updateElapsed, QUERY_EXECUTION_TIMER_INTERVAL_MS);
return () => globalThis.clearInterval(timer);
}, [executionRunToken, loading]);
return elapsedMs;
};
const WrapTextIcon: React.FC = () => (
<svg
className="gn-query-toolbar-word-wrap-icon"
@@ -171,23 +204,6 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
const [openToolbarMenu, setOpenToolbarMenu] = React.useState<QueryToolbarMenuKey | null>(null);
const [executionElapsedMs, setExecutionElapsedMs] = React.useState(0);
React.useEffect(() => {
if (!loading) {
setExecutionElapsedMs(0);
return;
}
const startedAt = Date.now();
const updateElapsed = () => setExecutionElapsedMs(Date.now() - startedAt);
updateElapsed();
const timer = window.setInterval(updateElapsed, QUERY_EXECUTION_TIMER_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [loading]);
const executionElapsedText = formatQueryExecutionElapsed(executionElapsedMs);
const executionElapsedLabel = t("query_editor.execution.elapsed", {
duration: executionElapsedText,
});
const updateToolbarMenuOpen = (key: QueryToolbarMenuKey, open: boolean) => {
setOpenToolbarMenu((current) => open ? key : current === key ? null : current);
};
@@ -430,20 +446,16 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
</Tooltip>
)}
{loading && (
<Tooltip title={`${t("query_editor.action.stop")} · ${executionElapsedLabel}`}>
<Tooltip title={t("query_editor.action.stop")}>
<Button
aria-label={`${t("query_editor.action.stop")}. ${executionElapsedLabel}`}
className={isV2Ui ? "gn-v2-query-toolbar-stop-action" : undefined}
aria-label={t("query_editor.action.stop")}
className={isV2Ui ? "gn-v2-query-toolbar-icon-action gn-v2-query-toolbar-stop-action" : undefined}
type="primary"
danger
icon={<StopOutlined />}
onClick={onCancel}
style={isV2Ui ? undefined : { minWidth: 166 }}
>
{!isV2Ui && t("query_editor.action.stop")}
<span className="gn-query-toolbar-execution-elapsed">
{executionElapsedText}
</span>
</Button>
</Tooltip>
)}

View File

@@ -316,21 +316,6 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar .ant-btn-icon-only {
flex: 0 0 34px;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-stop-action.ant-btn {
width: 128px !important;
min-width: 128px !important;
padding: 0 9px !important;
flex: 0 0 128px;
}
.gn-query-toolbar-execution-elapsed {
min-width: 10ch;
font-family: var(--gn-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
letter-spacing: 0;
text-align: left;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-menu-trigger {
display: inline-flex;
width: 34px;
@@ -385,6 +370,44 @@ body[data-ui-version="v2"] .gn-v2-query-monaco-shell {
background: var(--gn-monaco-bg, var(--gn-bg-panel));
}
.gn-query-execution-statusbar {
display: flex;
align-items: center;
min-height: 22px;
padding: 0 10px;
flex: 0 0 22px;
overflow: hidden;
background: inherit;
}
.gn-query-execution-timer {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
color: var(--gn-fg-3, rgba(0, 0, 0, 0.58));
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
.gn-query-execution-speed-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
flex: 0 0 16px;
line-height: 1;
}
.gn-query-execution-elapsed {
min-width: 10ch;
font-family: var(--gn-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
letter-spacing: 0;
text-align: left;
}
body[data-ui-version="v2"] .gn-v2-query-resizer {
height: 7px !important;
background: var(--gn-bg-panel-2) !important;