Skip to content

Commit 452b068

Browse files
authored
feat(sql-editor): add output history retention option (OtterMind#2270)
* feat(sql-editor): add output history retention option * fix(sql-editor): guard desktop execution races * test(sql-editor): cover terminal response ordering
1 parent ea82fde commit 452b068

15 files changed

Lines changed: 721 additions & 68 deletions

File tree

‎chat2db-community-client/scripts/i18n-source-hashes.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"es-ES": {
66
"ai.ts": "391a6e14c58d8401bf3bc245eca5bdd4b66efd8225a877bfbbcae01a1c1c019a",
77
"chat.ts": "0cbe009eacbaa95a9a29bbc69588cb547e90b563667c53edec3e54ee64f5667b",
8-
"common.ts": "9ae18f30c9bec0784fc6dca322c63dd440d4d3281e223dbddfd811685f63a7cd",
8+
"common.ts": "1f70d1ba81aecbbd985a9dd9d1ae8b18ddfb3d209982a2ca17fbe3e4bf883a1f",
99
"connection.ts": "41d07fd10c9550ae51995620cc998de5e99add35edb31c2bb592864c76c13688",
1010
"dashboard.ts": "b217bbe260c7674d4efad1dd1b61136653858884f5b9b2134cfdf7d1de5a44ee",
1111
"editTable.ts": "0f7a808f50053cd52b183d156bb4fd46bb125b8058f8774deb61c7f02894a350",
@@ -32,7 +32,7 @@
3232
"ko-KR": {
3333
"ai.ts": "391a6e14c58d8401bf3bc245eca5bdd4b66efd8225a877bfbbcae01a1c1c019a",
3434
"chat.ts": "0cbe009eacbaa95a9a29bbc69588cb547e90b563667c53edec3e54ee64f5667b",
35-
"common.ts": "9ae18f30c9bec0784fc6dca322c63dd440d4d3281e223dbddfd811685f63a7cd",
35+
"common.ts": "1f70d1ba81aecbbd985a9dd9d1ae8b18ddfb3d209982a2ca17fbe3e4bf883a1f",
3636
"connection.ts": "41d07fd10c9550ae51995620cc998de5e99add35edb31c2bb592864c76c13688",
3737
"dashboard.ts": "b217bbe260c7674d4efad1dd1b61136653858884f5b9b2134cfdf7d1de5a44ee",
3838
"editTable.ts": "0f7a808f50053cd52b183d156bb4fd46bb125b8058f8774deb61c7f02894a350",

‎chat2db-community-client/src/blocks/SearchResult/components/ExecutionConsole/executionConsolePreferences.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import assert from 'node:assert/strict';
22
import type { SqlExecutionLogRecord } from '@/service/sqlExecutionLog';
33
import {
4+
createExecutionConsoleKeepHistoryStorageKey,
45
createExecutionConsoleOrderStorageKey,
56
getLatestExecutionEdgeScrollTop,
67
orderExecutionLogRecords,
8+
persistExecutionConsoleKeepHistory,
79
persistExecutionConsoleOrder,
10+
readExecutionConsoleKeepHistory,
811
readExecutionConsoleOrder,
12+
subscribeExecutionConsoleKeepHistory,
913
} from './executionConsolePreferences';
1014

1115
function record(id: string, executionId: string, statementSequence: number): SqlExecutionLogRecord {
@@ -23,6 +27,7 @@ function record(id: string, executionId: string, statementSequence: number): Sql
2327
}
2428

2529
const storageKey = createExecutionConsoleOrderStorageKey('community', 'community');
30+
const keepHistoryStorageKey = createExecutionConsoleKeepHistoryStorageKey('community', 'community');
2631
const values = new Map<string, string>();
2732
const storage = {
2833
getItem: (key: string) => values.get(key) ?? null,
@@ -32,6 +37,7 @@ const storage = {
3237
};
3338

3439
assert.equal(storageKey, 'chat2db.community.community.execution-console.order.v1');
40+
assert.equal(keepHistoryStorageKey, 'chat2db.community.community.execution-console.keep-history.v1');
3541
assert.notEqual(
3642
storageKey,
3743
createExecutionConsoleOrderStorageKey('community', 'desktop'),
@@ -47,6 +53,35 @@ assert.equal(readExecutionConsoleOrder(storage, storageKey), 'newest-first');
4753
persistExecutionConsoleOrder(storage, storageKey, 'oldest-first');
4854
assert.equal(values.get(storageKey), 'oldest-first');
4955

56+
assert.equal(readExecutionConsoleKeepHistory(undefined, keepHistoryStorageKey), true);
57+
assert.equal(readExecutionConsoleKeepHistory(storage, keepHistoryStorageKey), true);
58+
values.set(keepHistoryStorageKey, 'invalid');
59+
assert.equal(readExecutionConsoleKeepHistory(storage, keepHistoryStorageKey), true);
60+
values.set(keepHistoryStorageKey, 'false');
61+
assert.equal(readExecutionConsoleKeepHistory(storage, keepHistoryStorageKey), false);
62+
persistExecutionConsoleKeepHistory(storage, keepHistoryStorageKey, true);
63+
assert.equal(values.get(keepHistoryStorageKey), 'true');
64+
65+
const keepHistoryUpdates: Array<[string, boolean]> = [];
66+
const secondKeepHistoryUpdates: Array<[string, boolean]> = [];
67+
const unsubscribeKeepHistory = subscribeExecutionConsoleKeepHistory((key, keepHistory) => {
68+
keepHistoryUpdates.push([key, keepHistory]);
69+
});
70+
const unsubscribeSecondKeepHistory = subscribeExecutionConsoleKeepHistory((key, keepHistory) => {
71+
secondKeepHistoryUpdates.push([key, keepHistory]);
72+
});
73+
persistExecutionConsoleKeepHistory(storage, keepHistoryStorageKey, false);
74+
assert.deepEqual(keepHistoryUpdates, [[keepHistoryStorageKey, false]]);
75+
assert.deepEqual(secondKeepHistoryUpdates, [[keepHistoryStorageKey, false]]);
76+
unsubscribeKeepHistory();
77+
persistExecutionConsoleKeepHistory(storage, keepHistoryStorageKey, true);
78+
assert.deepEqual(keepHistoryUpdates, [[keepHistoryStorageKey, false]], 'unsubscribed editors stop receiving updates');
79+
assert.deepEqual(secondKeepHistoryUpdates, [
80+
[keepHistoryStorageKey, false],
81+
[keepHistoryStorageKey, true],
82+
]);
83+
unsubscribeSecondKeepHistory();
84+
5085
const unavailableStorage = {
5186
getItem: () => {
5287
throw new Error('unavailable');
@@ -57,6 +92,8 @@ const unavailableStorage = {
5792
};
5893
assert.equal(readExecutionConsoleOrder(unavailableStorage, storageKey), 'oldest-first');
5994
assert.doesNotThrow(() => persistExecutionConsoleOrder(unavailableStorage, storageKey, 'newest-first'));
95+
assert.equal(readExecutionConsoleKeepHistory(unavailableStorage, keepHistoryStorageKey), true);
96+
assert.doesNotThrow(() => persistExecutionConsoleKeepHistory(unavailableStorage, keepHistoryStorageKey, false));
6097

6198
const records = [
6299
record('execution-1-statement-1', 'execution-1', 1),

‎chat2db-community-client/src/blocks/SearchResult/components/ExecutionConsole/executionConsolePreferences.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,21 @@ interface ExecutionConsolePreferenceStorage {
77
setItem(key: string, value: string): void;
88
}
99

10+
type ExecutionConsoleKeepHistoryListener = (storageKey: string, keepHistory: boolean) => void;
11+
1012
export const DEFAULT_EXECUTION_CONSOLE_ORDER: ExecutionConsoleOrder = 'oldest-first';
13+
export const DEFAULT_EXECUTION_CONSOLE_KEEP_HISTORY = true;
14+
15+
const keepHistoryListeners = new Set<ExecutionConsoleKeepHistoryListener>();
1116

1217
export function createExecutionConsoleOrderStorageKey(clientEdition: string, runtimeEnv: string) {
1318
return `chat2db.${clientEdition}.${runtimeEnv}.execution-console.order.v1`;
1419
}
1520

21+
export function createExecutionConsoleKeepHistoryStorageKey(clientEdition: string, runtimeEnv: string) {
22+
return `chat2db.${clientEdition}.${runtimeEnv}.execution-console.keep-history.v1`;
23+
}
24+
1625
export function getExecutionConsolePreferenceStorage(): ExecutionConsolePreferenceStorage | undefined {
1726
try {
1827
return typeof window === 'undefined' ? undefined : window.localStorage;
@@ -47,6 +56,38 @@ export function persistExecutionConsoleOrder(
4756
}
4857
}
4958

59+
export function readExecutionConsoleKeepHistory(
60+
storage: ExecutionConsolePreferenceStorage | undefined,
61+
storageKey: string,
62+
) {
63+
try {
64+
const storedValue = storage?.getItem(storageKey);
65+
return storedValue === 'false' ? false : DEFAULT_EXECUTION_CONSOLE_KEEP_HISTORY;
66+
} catch {
67+
return DEFAULT_EXECUTION_CONSOLE_KEEP_HISTORY;
68+
}
69+
}
70+
71+
export function persistExecutionConsoleKeepHistory(
72+
storage: ExecutionConsolePreferenceStorage | undefined,
73+
storageKey: string,
74+
keepHistory: boolean,
75+
) {
76+
try {
77+
storage?.setItem(storageKey, String(keepHistory));
78+
} catch {
79+
// Storage can be unavailable in restricted browser contexts.
80+
}
81+
keepHistoryListeners.forEach((listener) => listener(storageKey, keepHistory));
82+
}
83+
84+
export function subscribeExecutionConsoleKeepHistory(listener: ExecutionConsoleKeepHistoryListener) {
85+
keepHistoryListeners.add(listener);
86+
return () => {
87+
keepHistoryListeners.delete(listener);
88+
};
89+
}
90+
5091
export function orderExecutionLogRecords(
5192
records: readonly SqlExecutionLogRecord[],
5293
order: ExecutionConsoleOrder,

‎chat2db-community-client/src/blocks/SearchResult/components/ExecutionConsole/index.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,21 @@ const ORDER_STORAGE_KEY = createExecutionConsoleOrderStorageKey('community', __R
3131

3232
interface IProps {
3333
records: SqlExecutionLogRecord[];
34+
keepHistory: boolean;
3435
onClear: () => void;
36+
onKeepHistoryChange: (keepHistory: boolean) => void;
3537
onOpenResult: (resultKey: string) => void;
3638
isResultAvailable: (resultKey: string) => boolean;
3739
}
3840

39-
export default memo<IProps>(({ records, onClear, onOpenResult, isResultAvailable }) => {
41+
export default memo<IProps>(({
42+
records,
43+
keepHistory,
44+
onClear,
45+
onKeepHistoryChange,
46+
onOpenResult,
47+
isResultAvailable,
48+
}) => {
4049
const {
4150
styles,
4251
theme: { appearance },
@@ -111,6 +120,8 @@ export default memo<IProps>(({ records, onClear, onOpenResult, isResultAvailable
111120
onClear();
112121
} else if (key === 'follow') {
113122
handleToggleFollowLatest();
123+
} else if (key === 'keep-history') {
124+
onKeepHistoryChange(!keepHistory);
114125
} else if (key === 'toggle-order') {
115126
handleOrderChange(order === 'oldest-first' ? 'newest-first' : 'oldest-first');
116127
}
@@ -158,6 +169,11 @@ export default memo<IProps>(({ records, onClear, onOpenResult, isResultAvailable
158169
),
159170
label: i18n('common.button.followConsole'),
160171
},
172+
{
173+
key: 'keep-history',
174+
icon: <Check opacity={keepHistory ? 1 : 0} size={14} />,
175+
label: i18n('common.button.keepHistoryOutput'),
176+
},
161177
{ type: 'divider' },
162178
{ key: 'clear', icon: <Trash2 size={14} />, label: i18n('common.button.clearConsole'), danger: true },
163179
],

‎chat2db-community-client/src/blocks/SearchResult/index.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ interface IProps {
4141
executionLogRecords?: SqlExecutionLogRecord[];
4242
resultBatchKey?: number;
4343
forceOutputTab?: boolean;
44+
keepExecutionLogHistory?: boolean;
4445
viewTable?: boolean;
4546
onClearExecutionLog?: () => void;
47+
onKeepExecutionLogHistoryChange?: (keepHistory: boolean) => void;
4648
onResultDataListChange?: (params: {
4749
resultDataList: IManageResultData[];
4850
historyResultDataList: IManageResultData[];
@@ -315,7 +317,9 @@ const SearchResult = forwardRef((props: IProps, ref: ForwardedRef<ISearchResultR
315317
children: (
316318
<ExecutionConsole
317319
records={props.executionLogRecords || []}
320+
keepHistory={props.keepExecutionLogHistory ?? true}
318321
onClear={props.onClearExecutionLog || (() => {})}
322+
onKeepHistoryChange={props.onKeepExecutionLogHistoryChange || (() => {})}
319323
onOpenResult={handleOpenResult}
320324
isResultAvailable={isResultAvailable}
321325
/>
@@ -325,7 +329,9 @@ const SearchResult = forwardRef((props: IProps, ref: ForwardedRef<ISearchResultR
325329
}, [
326330
consoleMode,
327331
props.executionLogRecords,
332+
props.keepExecutionLogHistory,
328333
props.onClearExecutionLog,
334+
props.onKeepExecutionLogHistoryChange,
329335
handleOpenResult,
330336
isResultAvailable,
331337
styles.abstractIcon,

‎chat2db-community-client/src/hooks/useSqlExecutor.ts‎

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useState } from 'react';
1+
import { useCallback, useRef, useState } from 'react';
22
import { IManageResultData, IExecuteSqlParams } from '@/typings';
33
import executeSqlServer from '@/service/executeSql';
44
import useAbortRequest from './useAbortRequest';
@@ -12,18 +12,30 @@ import {
1212
import { v4 as uuidv4 } from 'uuid';
1313
import { useGlobalStore } from '@/store/global';
1414
import { settingSelectors } from '@/store/global/selectors';
15+
import {
16+
beginSqlExecutionRequest,
17+
createSqlExecutionRequestTracker,
18+
finishSqlExecutionRequest,
19+
getActiveSqlExecutionId,
20+
setSqlExecutionRequestId,
21+
SqlExecutionRequestTracker,
22+
} from '@/service/sqlExecutionRequestTracker';
1523

1624
interface IUseSqlExecutorProps {
1725
// Whether to return only one piece of data
1826
onlyOne?: boolean;
19-
onExecutionEvent?: (event: SqlExecutionEvent) => void;
27+
onExecutionRequestStart?: (requestSequence: number) => void;
28+
onExecutionEvent?: (event: SqlExecutionEvent, requestSequence: number) => void;
2029
}
2130

2231
const useSqlExecutor = (props?: IUseSqlExecutorProps) => {
23-
const { onlyOne, onExecutionEvent } = props || {};
32+
const { onlyOne, onExecutionRequestStart, onExecutionEvent } = props || {};
2433
const defaultPageSize = useGlobalStore((state) => settingSelectors.currentBaseSetting(state).defaultPageSize);
2534
const [executing, setExecuting] = useState(false);
26-
const [executionId, setExecutionId] = useState<string>();
35+
const executionRequestTrackerRef = useRef<SqlExecutionRequestTracker>();
36+
if (!executionRequestTrackerRef.current) {
37+
executionRequestTrackerRef.current = createSqlExecutionRequestTracker();
38+
}
2739
// interrupt request
2840
const [initSignal, abortRequest] = useAbortRequest();
2941

@@ -45,21 +57,26 @@ const useSqlExecutor = (props?: IUseSqlExecutorProps) => {
4557
};
4658
if (isDesktop && onExecutionEvent) {
4759
const requestUuid = uuidv4();
60+
const executionRequestTracker = executionRequestTrackerRef.current;
61+
const requestSequence = beginSqlExecutionRequest(executionRequestTracker);
62+
onExecutionRequestStart?.(requestSequence);
4863
setExecuting(true);
4964
return new Promise((resolve, reject) => {
5065
const subscription: { unsubscribe?: () => void } = {};
5166
subscription.unsubscribe = onSqlExecutionEvent(requestUuid, (event) => {
52-
onExecutionEvent(event);
67+
onExecutionEvent(event, requestSequence);
5368
if (event.eventType === 'finished') {
5469
subscription.unsubscribe?.();
55-
setExecuting(false);
56-
setExecutionId(undefined);
70+
if (finishSqlExecutionRequest(executionRequestTracker, requestSequence)) {
71+
setExecuting(false);
72+
}
5773
resolve([]);
5874
}
5975
if (event.eventType === 'failed' || event.eventType === 'cancelled') {
6076
subscription.unsubscribe?.();
61-
setExecuting(false);
62-
setExecutionId(undefined);
77+
if (finishSqlExecutionRequest(executionRequestTracker, requestSequence)) {
78+
setExecuting(false);
79+
}
6380
if (event.eventType === 'cancelled') {
6481
resolve([]);
6582
} else {
@@ -71,15 +88,19 @@ const useSqlExecutor = (props?: IUseSqlExecutorProps) => {
7188
.then((res) => {
7289
if (!res?.executionId) {
7390
subscription.unsubscribe?.();
74-
setExecuting(false);
91+
if (finishSqlExecutionRequest(executionRequestTracker, requestSequence)) {
92+
setExecuting(false);
93+
}
7594
reject(getStartExecutionError(res));
7695
return;
7796
}
78-
setExecutionId(res.executionId);
97+
setSqlExecutionRequestId(executionRequestTracker, requestSequence, res.executionId);
7998
})
8099
.catch((err) => {
81100
subscription.unsubscribe?.();
82-
setExecuting(false);
101+
if (finishSqlExecutionRequest(executionRequestTracker, requestSequence)) {
102+
setExecuting(false);
103+
}
83104
reject(err);
84105
});
85106
});
@@ -104,17 +125,18 @@ const useSqlExecutor = (props?: IUseSqlExecutorProps) => {
104125
setExecuting(false);
105126
});
106127
});
107-
}, [defaultPageSize, onExecutionEvent]);
128+
}, [defaultPageSize, onExecutionEvent, onExecutionRequestStart]);
108129

109130
// Stop executing sql
110131
const stopExecuteSQL = useCallback(() => {
111-
if (isDesktop && executionId) {
112-
cancelSqlExecution(executionId);
132+
const activeExecutionId = getActiveSqlExecutionId(executionRequestTrackerRef.current!);
133+
if (isDesktop && activeExecutionId) {
134+
cancelSqlExecution(activeExecutionId);
113135
return;
114136
}
115137
abortRequest();
116138
setExecuting(false);
117-
}, [abortRequest, executionId]);
139+
}, [abortRequest]);
118140

119141
return {
120142
executing,

‎chat2db-community-client/src/i18n/en-US/common.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export default {
227227
'common.button.copyConsole': 'Copy output',
228228
'common.button.clearConsole': 'Clear output',
229229
'common.button.followConsole': 'Follow latest entry',
230+
'common.button.keepHistoryOutput': 'Keep history output',
230231
'common.text.order': 'Order',
231232
'common.text.oldestFirst': 'Oldest first',
232233
'common.text.newestFirst': 'Newest first',

‎chat2db-community-client/src/i18n/es-ES/common.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export default {
264264
'common.button.copyConsole': 'Copiar salida',
265265
'common.button.clearConsole': 'Limpiar salida',
266266
'common.button.followConsole': 'Seguir la última entrada',
267+
'common.button.keepHistoryOutput': 'Conservar el historial de salida',
267268
'common.text.order': 'Orden',
268269
'common.text.oldestFirst': 'Antiguos primero',
269270
'common.text.newestFirst': 'Nuevos primero',

‎chat2db-community-client/src/i18n/ja-JP/common.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export default {
227227
'common.button.copyConsole': '出力をコピー',
228228
'common.button.clearConsole': '出力をクリア',
229229
'common.button.followConsole': '最新のログを追従',
230+
'common.button.keepHistoryOutput': '出力履歴を保持',
230231
'common.text.order': '並び順',
231232
'common.text.oldestFirst': '古い順',
232233
'common.text.newestFirst': '新しい順',

‎chat2db-community-client/src/i18n/ko-KR/common.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export default {
264264
'common.button.copyConsole': '출력 복사',
265265
'common.button.clearConsole': '출력 지우기',
266266
'common.button.followConsole': '최신 로그 따라가기',
267+
'common.button.keepHistoryOutput': '출력 기록 유지',
267268
'common.text.order': '정렬',
268269
'common.text.oldestFirst': '오래된 순',
269270
'common.text.newestFirst': '최신 순',

0 commit comments

Comments
 (0)