mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
🐛 fix(query-editor): 将执行耗时固定到编辑器底部
- 将计时状态移至 Monaco 底部并在执行完成后保留耗时 - 按一秒和五秒边界显示闪电、兔子或乌龟速度图标 - 保持工具栏停止按钮紧凑并补齐计时生命周期与布局回归
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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;');
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user