diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f1d97d75d..5bf273ae45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: Run active workspace tab locator contract run: yarn run test:active-tab-locator + - name: Run local file tab title contract + run: yarn run test:local-file-tab-title + - name: Run saved console tree refresh contract run: yarn run test:saved-console-tree-refresh @@ -63,15 +66,48 @@ jobs: - name: Run database object sorting contract run: yarn run test:database-object-sorting + - name: Run BI chart sorting contract + run: yarn run test:bi-chart-sorting + + - name: Run BI chart SQL execution result contract + run: yarn run test:bi-sql-result + + - name: Run ComboAxisSelect action filter contract + run: yarn run test:combo-axis-actions + + - name: Run BI label rotation contract + run: yarn run test:label-rotation + - name: Run Redis explorer contract run: yarn run test:redis-explorer + - name: Run CreateStream index mapping contract + run: yarn run test:create-stream-index + - name: Run result inspector contract run: yarn run test:result-inspector + - name: Run result operation record contract + run: yarn run test:result-operation-record + + - name: Run result tab preference contract + run: yarn run test:result-tab-preferences + + - name: Run execution console preference contract + run: yarn run test:execution-console + - name: Run SQL execution log contract run: yarn run test:sql-execution-log + - name: Run SQL execution batch contract + run: yarn run test:sql-execution-batch + + - name: Run SQL execution request tracker contract + run: yarn run test:sql-execution-request-tracker + + - name: Run SQL execution stream contract + run: yarn run test:sql-execution-stream + - name: Run verification-code countdown lifecycle contract run: yarn run test:verification-code-countdown diff --git a/.gitignore b/.gitignore index 16365d626a..8d5e0e9d6c 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ package-lock.json /chat2db-community-server/.apifox-helper.properties /chat2db-community-server/.easy.api.config /chat2db-community-server/latest_version.json +.java-version diff --git a/chat2db-community-client/package.json b/chat2db-community-client/package.json index b79cc0bfa6..7002dbc38b 100644 --- a/chat2db-community-client/package.json +++ b/chat2db-community-client/package.json @@ -13,7 +13,7 @@ "build:desktop": "npm run build:web:desktop", "build:prod": "npm run build:web:prod", "build:web": "umi build", - "prebuild:web:community": "yarn test:chat-answer-update && yarn test:tree-title-highlight && yarn test:ai-model-select", + "prebuild:web:community": "yarn test:chat-answer-update && yarn test:tree-title-highlight && yarn test:tree-data-update && yarn test:tree-node-lookup && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections", "build:web:2java": "yarn run build:web:prod && rm -rf ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/* && cp -r dist/index.html ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/", "build:web:desktop": "cross-env UMI_ENV=desktop cross-env APP_NAME=chat2db-pro cross-env APP_VERSION=${npm_config_app_version} cross-env PRINT_LOGS=${npm_config_print_logs} cross-env APP_PORT=${npm_config_app_port} umi build", "build:web:desktop:test": "cross-env UMI_ENV=desktop cross-env APP_NAME=chat2db-pro-test cross-env APP_VERSION=${npm_config_app_version} cross-env PRINT_LOGS=${npm_config_print_logs} cross-env APP_PORT=${npm_config_app_port} umi build", @@ -28,23 +28,42 @@ "lint:eslint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --max-warnings=0", "lint:style": "stylelint \"src/**/*.{css,less}\" --max-warnings=0", "test:i18n": "node ./scripts/validate-i18n.cjs", + "test:ai-model-config": "tsx src/blocks/AI/components/AIModelConfigModal/modelConfigDefaults.test.ts", "test:ai-model-select": "tsx src/blocks/AI/components/AIModelSelect/modelSelectOptions.test.ts", "test:active-tab-locator": "tsx src/pages/main/workspace/utils/activeTabLocator.test.ts", "test:base-table-interaction": "tsx src/components/BaseTable/treeInteraction.test.ts", + "test:bi-chart-sorting": "tsx src/blocks/BI/Chart/charts/BarChart/dataTreating.test.ts", + "test:bi-sql-result": "tsx src/blocks/BI/ChartCardBox/EditorChartSql/executionResult.test.ts", "test:chat-answer-update": "tsx src/store/chat/slices/chatDetails/answerUpdates.test.ts", + "test:combo-axis-actions": "tsx src/blocks/BI/Chart/form/components/ComboAxisSelect/comboAxisSelect.test.ts", "test:database-object-sorting": "tsx src/blocks/NewTree/utils/sortTreeNodes.test.ts", "test:execution-console": "tsx src/blocks/SearchResult/components/ExecutionConsole/executionConsolePreferences.test.ts", + "test:export-connections": "tsx src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnectionsFlow.test.ts", + "test:label-rotation": "tsx src/blocks/BI/Chart/components/EChartsContainer/hooks/useLabelRotate.test.ts", "test:redis-explorer": "tsx src/blocks/RedisAllData/redisExplorer.test.ts", "test:result-markdown": "tsx src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts", + "test:result-tab-preferences": "tsx src/blocks/SearchResult/resultTabPreferences.test.ts && tsx src/service/resultTabShortcut.test.ts", "test:result-inspector": "tsx src/store/workspace/utils/resultInspector.test.ts && yarn test:search-result-tab-selection", + "test:result-operation-record": "tsx src/blocks/SearchResult/components/ResultSetTable/hooks/useOperationRecord.test.tsx && tsx src/blocks/SearchResult/components/ResultSetTable/event/onChangeCellValue/index.test.ts", "test:search-result-query-composer": "tsx src/blocks/SearchResult/components/ScreeningResult/queryComposer.test.ts", "test:search-result-tab-selection": "tsx src/blocks/SearchResult/tabSelection.test.ts", "test:saved-console-tree-refresh": "tsx src/store/tree/savedConsoleTreeRefresh.test.ts", - "test:saved-console-lifecycle": "tsx src/store/workspace/utils/savedConsoleLifecycle.test.ts", + "test:saved-console-lifecycle": "tsx src/store/workspace/utils/savedConsoleLifecycle.test.ts && tsx src/store/workspace/utils/workspaceTabPersistence.test.ts", "test:settings-layout": "tsx src/blocks/Setting/navigation.test.ts && tsx src/blocks/Setting/search.test.ts && tsx src/blocks/Setting/BaseSetting/model.test.ts && tsx src/blocks/Setting/settingsLayout.test.ts", + "test:shortcut": "tsx src/constants/shortcut.test.ts", "test:sql-execution-log": "tsx src/service/sqlExecutionLog.test.ts", + "test:sql-execution-batch": "tsx src/service/sqlExecutionBatch.test.ts", + "test:sql-execution-request-tracker": "tsx src/service/sqlExecutionRequestTracker.test.ts", + "test:sql-execution-stream": "tsx src/service/sqlExecutionStream.test.ts", + "test:local-file-tab-title": "tsx src/pages/main/workspace/utils/localTextFile.test.ts", + "test:local-file-tab-refresh": "tsx src/store/workspace/utils/localFileWorkspaceTab.test.ts", + "test:sql-completion-context": "tsx src/components/SQLEditor/core/sqlCompletionContext.test.ts", "test:sql-in-clipboard": "tsx src/utils/sqlInClipboard.test.ts && tsx src/components/SQLEditor/helper/sqlInsertValueDefaults.test.ts", "test:tree-title-highlight": "tsx src/blocks/NewTree/components/TitleRender/highlightSearchText.test.ts", + "test:tree-data-update": "tsx src/store/tree/treeDataUpdate.test.ts", + "test:tree-node-lookup": "tsx src/utils/treeNodeLookup.test.ts", + "test:terminal": "tsx src/constants/terminal.test.ts && tsx src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/quickTerminal.test.ts && tsx src/pages/main/workspace/components/WorkspaceTabs/terminalTabPlacement.test.ts", + "test:workspace-tab-drag": "tsx src/pages/main/workspace/components/WorkspaceTabs/workspaceTabDrop.test.ts", "test:verification-code-countdown": "tsx src/utils/verificationCodeCountdown.test.ts && tsx src/utils/latestRequest.test.ts", "modify:package:name": "node ./scripts/modify-package-name.js", "start": "APP_VERSION=${npm_config_app_version} UMI_DEV_SERVER_COMPRESS=none umi dev", @@ -53,7 +72,8 @@ "start:community:hot": "cross-env UMI_ENV=community cross-env APP_NAME=chat2db-community cross-env DISABLE_MFSU=true cross-env UMI_DEV_SERVER_COMPRESS=none cross-env PORT=8889 umi dev --public_path=/", "start:offline:hot": "cross-env UMI_ENV=offline cross-env PORT=8888 umi dev", "start:proxy:online": "cross-env HMR=none umi dev --webapp=true --public_path=http://localhost:8000/", - "ui": "yarn add @chat2db/ui" + "ui": "yarn add @chat2db/ui", + "test:create-stream-index": "tsx src/blocks/RedisAllData/CreateStream/mapDisplayedIndex.test.ts" }, "dependencies": { "@chat2db/ui": "^1.45.3", @@ -66,6 +86,9 @@ "@visactor/vtable-editors": "^1.10.1", "@visactor/vtable-search": "^1.13.0", "@xyflow/react": "^12.4.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/xterm": "^6.0.0", "ahooks": "^3.7.8", "ali-oss": "^6.20.0", "ali-react-table": "^2.6.1", @@ -85,10 +108,12 @@ "html2canvas": "^1.4.1", "idb-keyval": "^6.2.1", "is-online": "^11.0.0", + "katex": "^0.16.21", "localforage": "^1.10.0", "lodash": "^4.17.21", "lottie-react": "^2.4.0", "lucide-react": "^0.356.0", + "mermaid": "^11.12.0", "monaco-editor": "^0.52.0", "node-schedule": "^2.1.1", "pako": "^2.1.0", @@ -98,16 +123,17 @@ "react-infinite-scroll-component": "^6.1.0", "react-mentions": "^4.4.10", "react-resizable": "^3.0.5", + "react-markdown": "^9.0.3", "react-sortablejs": "^6.1.4", "react-split-pane": "^0.1.92", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "umi": "^4.0.87", "umi-request": "^1.4.0", "url-parse": "^1.5.10", "utility-types": "^3.11.0", "uuid": "^9.0.0", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0", - "xterm-addon-web-links": "^0.9.0", "zod": "^3.24.2", "zustand": "^4.4.4" }, diff --git a/chat2db-community-client/scripts/i18n-source-hashes.json b/chat2db-community-client/scripts/i18n-source-hashes.json index 86de05f2ca..4488f9221f 100644 --- a/chat2db-community-client/scripts/i18n-source-hashes.json +++ b/chat2db-community-client/scripts/i18n-source-hashes.json @@ -5,8 +5,8 @@ "es-ES": { "ai.ts": "391a6e14c58d8401bf3bc245eca5bdd4b66efd8225a877bfbbcae01a1c1c019a", "chat.ts": "0cbe009eacbaa95a9a29bbc69588cb547e90b563667c53edec3e54ee64f5667b", - "common.ts": "1f70d1ba81aecbbd985a9dd9d1ae8b18ddfb3d209982a2ca17fbe3e4bf883a1f", - "connection.ts": "41d07fd10c9550ae51995620cc998de5e99add35edb31c2bb592864c76c13688", + "common.ts": "94c74ecece9c1ad0853468ee9b334c8221af9c4c44a73989704dacff4f41e401", + "connection.ts": "d70578a3444978b60ea41c670e8b180849eaab297e33325db56c891e1058e899", "dashboard.ts": "b217bbe260c7674d4efad1dd1b61136653858884f5b9b2134cfdf7d1de5a44ee", "editTable.ts": "0f7a808f50053cd52b183d156bb4fd46bb125b8058f8774deb61c7f02894a350", "editTableData.ts": "95261152bcd5a627dad2e82cba3603faeafc33965da6ebe4a8862090a19ad6e5", @@ -16,24 +16,24 @@ "license.ts": "6aff8c38b1a7475264aa1e16e88d9b9731ecef112af70fe53edab449788ea788", "login.ts": "d6b15f2b4db713711c093108372027ad65899aec6dd963d3f954231a9ce5b890", "menu.ts": "1f9adabb7465ed545bdaa57fd2a0c10913e04979e710f65c6822f295c4d3e6ed", - "monaco.ts": "da4d9ea1a29de505f94ca9c49d125805fbef1c3132c7aa0f49f1c278b8cb1e93", + "monaco.ts": "b4c3df44f674093f2ad58ad76b7c94bd4c74443e38435f4867b92e5fe7bb6b0b", "notification.ts": "500751da2f02391622f074448eb46611fd41da2e5530d6c7d65f17a1382bd236", "plugin.ts": "01f15b6342cc5b29899ee4144d67b04bb4bc02c6bb10779806be322dc5e9a2bc", "price.ts": "26e35cb96368585f83a29adbb14e300da9b7f5278a4320063e81a1d1dd0adee9", "redis.ts": "a73e4fca6d783031370060e20bb1bbba13f663a0773cd031e1744be1cb4e93af", - "setting.ts": "447e759e9842d790e80e6d5680032bf150ec259c6d48daa87458277a78a9ba1f", + "setting.ts": "9728c7f38cd3a20fda257a304366cc9493e490c6e941fe455f1b3d11522ef3ca", "spaceSetting.ts": "defdf3138e5e1782f0282760bcef4ce1b8550cc80b03052fea468d97165cb980", "sqlEditor.ts": "5c41e5f24d5ba78909795bcc681cabb8460cca075233b665dc4940afe82ce0c9", "stream.ts": "8418dd25b0fd54b2ef2b15a2573edf0a4876efb4e69936ff82a157ea5cf42be4", "team.ts": "eb1fb03bdfeb93169ef1d2e347deb5b95071c10c8c93c35cfe2cb5b6606779ea", "userGuide.ts": "0103ba35af95a3a0ff7a414130b68a7dd5f5f8196e59cd951631ac2090cb04fe", - "workspace.ts": "846c4ea2acd392fa026473cf216bfaf3f62c1a4355be872efa939aa199468c8d" + "workspace.ts": "fd9c276d878e08b73fce53188b0a190c01b2367bac43fd43812a5085554a545b" }, "ko-KR": { "ai.ts": "391a6e14c58d8401bf3bc245eca5bdd4b66efd8225a877bfbbcae01a1c1c019a", "chat.ts": "0cbe009eacbaa95a9a29bbc69588cb547e90b563667c53edec3e54ee64f5667b", - "common.ts": "1f70d1ba81aecbbd985a9dd9d1ae8b18ddfb3d209982a2ca17fbe3e4bf883a1f", - "connection.ts": "41d07fd10c9550ae51995620cc998de5e99add35edb31c2bb592864c76c13688", + "common.ts": "94c74ecece9c1ad0853468ee9b334c8221af9c4c44a73989704dacff4f41e401", + "connection.ts": "d70578a3444978b60ea41c670e8b180849eaab297e33325db56c891e1058e899", "dashboard.ts": "b217bbe260c7674d4efad1dd1b61136653858884f5b9b2134cfdf7d1de5a44ee", "editTable.ts": "0f7a808f50053cd52b183d156bb4fd46bb125b8058f8774deb61c7f02894a350", "editTableData.ts": "95261152bcd5a627dad2e82cba3603faeafc33965da6ebe4a8862090a19ad6e5", @@ -43,18 +43,18 @@ "license.ts": "6aff8c38b1a7475264aa1e16e88d9b9731ecef112af70fe53edab449788ea788", "login.ts": "d6b15f2b4db713711c093108372027ad65899aec6dd963d3f954231a9ce5b890", "menu.ts": "1f9adabb7465ed545bdaa57fd2a0c10913e04979e710f65c6822f295c4d3e6ed", - "monaco.ts": "da4d9ea1a29de505f94ca9c49d125805fbef1c3132c7aa0f49f1c278b8cb1e93", + "monaco.ts": "b4c3df44f674093f2ad58ad76b7c94bd4c74443e38435f4867b92e5fe7bb6b0b", "notification.ts": "500751da2f02391622f074448eb46611fd41da2e5530d6c7d65f17a1382bd236", "plugin.ts": "01f15b6342cc5b29899ee4144d67b04bb4bc02c6bb10779806be322dc5e9a2bc", "price.ts": "26e35cb96368585f83a29adbb14e300da9b7f5278a4320063e81a1d1dd0adee9", "redis.ts": "a73e4fca6d783031370060e20bb1bbba13f663a0773cd031e1744be1cb4e93af", - "setting.ts": "447e759e9842d790e80e6d5680032bf150ec259c6d48daa87458277a78a9ba1f", + "setting.ts": "9728c7f38cd3a20fda257a304366cc9493e490c6e941fe455f1b3d11522ef3ca", "spaceSetting.ts": "defdf3138e5e1782f0282760bcef4ce1b8550cc80b03052fea468d97165cb980", "sqlEditor.ts": "5c41e5f24d5ba78909795bcc681cabb8460cca075233b665dc4940afe82ce0c9", "stream.ts": "8418dd25b0fd54b2ef2b15a2573edf0a4876efb4e69936ff82a157ea5cf42be4", "team.ts": "eb1fb03bdfeb93169ef1d2e347deb5b95071c10c8c93c35cfe2cb5b6606779ea", "userGuide.ts": "0103ba35af95a3a0ff7a414130b68a7dd5f5f8196e59cd951631ac2090cb04fe", - "workspace.ts": "846c4ea2acd392fa026473cf216bfaf3f62c1a4355be872efa939aa199468c8d" + "workspace.ts": "fd9c276d878e08b73fce53188b0a190c01b2367bac43fd43812a5085554a545b" } } } diff --git a/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/index.tsx b/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/index.tsx index a046c05d46..404112f579 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/index.tsx +++ b/chat2db-community-client/src/blocks/AI/components/AIModelConfigModal/index.tsx @@ -11,6 +11,7 @@ import { saveAIModelConfig, testAIModelConfig, } from '@/service/aiModelConfig'; +import { resolveBaseUrlOnProviderChange, resolveProviderBaseUrl } from './modelConfigDefaults'; import { useStyles } from './style'; interface AIModelConfigModalProps { @@ -23,6 +24,7 @@ const providerOptions = [ { label: 'OpenAI', value: 'OPENAI' }, { label: 'Claude', value: 'CLAUDE' }, { label: 'Gemini', value: 'GEMINI' }, + { label: 'MiniMax', value: 'MINIMAX' }, ]; const emptyFormValues: IAIModelConfigSaveRequest = { @@ -39,6 +41,18 @@ const emptyFormValues: IAIModelConfigSaveRequest = { defaultConfig: false, }; +const toFormValues = (config?: Partial): IAIModelConfigSaveRequest => { + const values = { + ...emptyFormValues, + ...config, + apiKey: '', + }; + return { + ...values, + baseUrl: resolveProviderBaseUrl(values.provider, values.baseUrl), + }; +}; + export default function AIModelConfigModal({ open, onClose, onChanged }: AIModelConfigModalProps) { const { styles, cx } = useStyles(); const [modal, modalContextHolder] = Modal.useModal(); @@ -70,11 +84,7 @@ export default function AIModelConfigModal({ open, onClose, onChanged }: AIModel const activeId = editingId && list.some((item) => item.id === editingId) ? editingId : list[0].id; setEditingId(activeId); const activeConfig = list.find((item) => item.id === activeId); - form.setFieldsValue({ - ...emptyFormValues, - ...activeConfig, - apiKey: '', - }); + form.setFieldsValue(toFormValues(activeConfig)); } else { resetForm(); } @@ -91,11 +101,11 @@ export default function AIModelConfigModal({ open, onClose, onChanged }: AIModel const handleSelectConfig = (config: IAIModelConfigItem) => { setEditingId(config.id); - form.setFieldsValue({ - ...emptyFormValues, - ...config, - apiKey: '', - }); + form.setFieldsValue(toFormValues(config)); + }; + + const handleProviderChange = (provider: AIProvider) => { + form.setFieldValue('baseUrl', resolveBaseUrlOnProviderChange(provider, form.getFieldValue('baseUrl'))); }; const handleSave = async () => { @@ -264,7 +274,11 @@ export default function AIModelConfigModal({ open, onClose, onChanged }: AIModel label={i18n('setting.modelConfig.provider')} rules={[{ required: true, message: i18n('setting.modelConfig.validation.provider') }]} > - { + if (provider === 'MINIMAX' && !baseUrl?.trim()) { + return DEFAULT_MINIMAX_BASE_URL; + } + return baseUrl || ''; +}; + +export const resolveBaseUrlOnProviderChange = (provider: AIProvider, baseUrl?: string): string => { + if (provider === 'MINIMAX') { + return resolveProviderBaseUrl(provider, baseUrl); + } + if (baseUrl?.trim() === DEFAULT_MINIMAX_BASE_URL) { + return ''; + } + return baseUrl || ''; +}; diff --git a/chat2db-community-client/src/blocks/BI/Chart/charts/BarChart/dataTreating.test.ts b/chat2db-community-client/src/blocks/BI/Chart/charts/BarChart/dataTreating.test.ts new file mode 100644 index 0000000000..37735f18ae --- /dev/null +++ b/chat2db-community-client/src/blocks/BI/Chart/charts/BarChart/dataTreating.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { OrderByRule } from '../../constants'; +import { compareChartValues, sortChartDataIndices } from '../sortChartData'; + +const dataToSort = [30, 10, 20, 10, 30]; + +assert.deepEqual(sortChartDataIndices(dataToSort, OrderByRule.ASC), [1, 3, 2, 0, 4]); +assert.deepEqual(sortChartDataIndices(dataToSort, OrderByRule.DESC), [0, 4, 2, 1, 3]); +assert.equal(compareChartValues(30, 30), 0); +assert.equal(compareChartValues('same', 'same'), 0); +assert.deepEqual(sortChartDataIndices([42], OrderByRule.ASC), [0]); +assert.deepEqual(sortChartDataIndices([], OrderByRule.ASC), []); + +console.log('BI chart comparator tests passed'); diff --git a/chat2db-community-client/src/blocks/BI/Chart/charts/BarChart/dataTreating.ts b/chat2db-community-client/src/blocks/BI/Chart/charts/BarChart/dataTreating.ts index 4119095405..269a7daecb 100644 --- a/chat2db-community-client/src/blocks/BI/Chart/charts/BarChart/dataTreating.ts +++ b/chat2db-community-client/src/blocks/BI/Chart/charts/BarChart/dataTreating.ts @@ -1,5 +1,6 @@ import { INormalizedData, ChartSchema } from '@/blocks/BI/Chart/typings'; -import { OrderByRule, OrderByType } from '../../constants'; +import { OrderByType } from '../../constants'; +import { sortChartDataIndices } from '../sortChartData'; export interface DataTreatingProps { data?: INormalizedData; @@ -35,12 +36,7 @@ export const barDataTreating = (props: DataTreatingProps) => { if (orderByType === OrderByType.X_AXIS || orderByType === OrderByType.Y_AXIS) { const isXAxis = orderByType === OrderByType.X_AXIS; const dataToSort = isXAxis ? xAxisData : yAxisData; - const sortedIndices = dataToSort - .map((_, index) => index) - .sort((a, b) => { - const compareResult = dataToSort[a] < dataToSort[b] ? -1 : 1; - return orderByRule === OrderByRule.ASC ? compareResult : -compareResult; - }); + const sortedIndices = sortChartDataIndices(dataToSort, orderByRule); xAxisData = sortedIndices.map((index) => xAxisData[index]); yAxisData = sortedIndices.map((index) => yAxisData[index]); diff --git a/chat2db-community-client/src/blocks/BI/Chart/charts/ComboChart/dataTreating.ts b/chat2db-community-client/src/blocks/BI/Chart/charts/ComboChart/dataTreating.ts index 61d9ae8034..01c2b9b9a2 100644 --- a/chat2db-community-client/src/blocks/BI/Chart/charts/ComboChart/dataTreating.ts +++ b/chat2db-community-client/src/blocks/BI/Chart/charts/ComboChart/dataTreating.ts @@ -1,5 +1,6 @@ import { INormalizedData, ChartSchema } from '@/blocks/BI/Chart/typings'; -import { ChartType, LineType, OrderByRule, OrderByType } from '../../constants'; +import { ChartType, LineType, OrderByType } from '../../constants'; +import { sortChartDataIndices } from '../sortChartData'; export interface DataTreatingProps { data?: INormalizedData; @@ -77,12 +78,7 @@ export const barDataTreating = (props: DataTreatingProps) => { const isXAxis = orderByType === OrderByType.X_AXIS; const dataToSort = isXAxis ? xAxisData : yAxisData; - const sortedIndices = dataToSort - .map((_, index) => index) - .sort((a, b) => { - const compareResult = dataToSort[a] < dataToSort[b] ? -1 : 1; - return orderByRule === OrderByRule.ASC ? compareResult : -compareResult; - }); + const sortedIndices = sortChartDataIndices(dataToSort, orderByRule); xAxisData = sortedIndices.map((index) => xAxisData[index]); yAxisData = sortedIndices.map((index) => yAxisData[index]); diff --git a/chat2db-community-client/src/blocks/BI/Chart/charts/LineChart/dataTreating.ts b/chat2db-community-client/src/blocks/BI/Chart/charts/LineChart/dataTreating.ts index 4119095405..269a7daecb 100644 --- a/chat2db-community-client/src/blocks/BI/Chart/charts/LineChart/dataTreating.ts +++ b/chat2db-community-client/src/blocks/BI/Chart/charts/LineChart/dataTreating.ts @@ -1,5 +1,6 @@ import { INormalizedData, ChartSchema } from '@/blocks/BI/Chart/typings'; -import { OrderByRule, OrderByType } from '../../constants'; +import { OrderByType } from '../../constants'; +import { sortChartDataIndices } from '../sortChartData'; export interface DataTreatingProps { data?: INormalizedData; @@ -35,12 +36,7 @@ export const barDataTreating = (props: DataTreatingProps) => { if (orderByType === OrderByType.X_AXIS || orderByType === OrderByType.Y_AXIS) { const isXAxis = orderByType === OrderByType.X_AXIS; const dataToSort = isXAxis ? xAxisData : yAxisData; - const sortedIndices = dataToSort - .map((_, index) => index) - .sort((a, b) => { - const compareResult = dataToSort[a] < dataToSort[b] ? -1 : 1; - return orderByRule === OrderByRule.ASC ? compareResult : -compareResult; - }); + const sortedIndices = sortChartDataIndices(dataToSort, orderByRule); xAxisData = sortedIndices.map((index) => xAxisData[index]); yAxisData = sortedIndices.map((index) => yAxisData[index]); diff --git a/chat2db-community-client/src/blocks/BI/Chart/charts/sortChartData.ts b/chat2db-community-client/src/blocks/BI/Chart/charts/sortChartData.ts new file mode 100644 index 0000000000..af330c1cb6 --- /dev/null +++ b/chat2db-community-client/src/blocks/BI/Chart/charts/sortChartData.ts @@ -0,0 +1,19 @@ +import { OrderByRule } from '../constants'; + +export type ChartSortValue = number | string; + +export const compareChartValues = (left: ChartSortValue, right: ChartSortValue): number => { + return left < right ? -1 : left > right ? 1 : 0; +}; + +export const sortChartDataIndices = ( + dataToSort: readonly ChartSortValue[], + orderByRule: OrderByRule, +): number[] => { + return dataToSort + .map((_, index) => index) + .sort((a, b) => { + const compareResult = compareChartValues(dataToSort[a], dataToSort[b]); + return orderByRule === OrderByRule.ASC ? compareResult : -compareResult; + }); +}; diff --git a/chat2db-community-client/src/blocks/BI/Chart/components/EChartsContainer/hooks/useLabelRotate.test.ts b/chat2db-community-client/src/blocks/BI/Chart/components/EChartsContainer/hooks/useLabelRotate.test.ts new file mode 100644 index 0000000000..62296ae0f0 --- /dev/null +++ b/chat2db-community-client/src/blocks/BI/Chart/components/EChartsContainer/hooks/useLabelRotate.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { calculateLabelRotation, getChartLabelText } from './useLabelRotate'; + +assert.equal(getChartLabelText('hello'), 'hello'); +assert.equal(getChartLabelText(42), '42'); +assert.equal(getChartLabelText(0), '0'); +assert.equal(getChartLabelText(-1.5), '-1.5'); +assert.equal(getChartLabelText({ formattedLabel: 'fmt', rawLabel: 'raw' }), 'fmt'); +assert.equal(getChartLabelText({ rawLabel: 'raw' }), 'raw'); +assert.equal(getChartLabelText({}), ''); +assert.equal(getChartLabelText(null), 'null'); +assert.equal(getChartLabelText(undefined), 'undefined'); + +assert.deepEqual(calculateLabelRotation(400, 4, 50), { rotate: 0, interval: 0 }); +assert.deepEqual(calculateLabelRotation(120, 4, 40), { rotate: 45, interval: 0 }); +assert.deepEqual(calculateLabelRotation(80, 4, 40), { rotate: 90, interval: 0 }); +assert.deepEqual(calculateLabelRotation(40, 4, 40), { rotate: 90, interval: 2 }); + +console.log('useLabelRotate label normalization and rotation tests passed'); diff --git a/chat2db-community-client/src/blocks/BI/Chart/components/EChartsContainer/hooks/useLabelRotate.tsx b/chat2db-community-client/src/blocks/BI/Chart/components/EChartsContainer/hooks/useLabelRotate.tsx index bea3c903e6..6423ac2d13 100644 --- a/chat2db-community-client/src/blocks/BI/Chart/components/EChartsContainer/hooks/useLabelRotate.tsx +++ b/chat2db-community-client/src/blocks/BI/Chart/components/EChartsContainer/hooks/useLabelRotate.tsx @@ -1,6 +1,41 @@ import { useCallback } from 'react'; import _ from 'lodash'; +type EChartsLabel = { + formattedLabel?: string; + rawLabel?: string; +}; + +export const getChartLabelText = (label: unknown): string => { + if (label !== null && typeof label === 'object') { + const { formattedLabel, rawLabel } = label as EChartsLabel; + return formattedLabel || rawLabel || ''; + } + return String(label); +}; + +export const calculateLabelRotation = ( + containerWidth: number, + labelCount: number, + longestLabelWidth: number, +): { rotate: number; interval: number } => { + const availableWidth = containerWidth / labelCount; + let rotate = 0; + let interval = 0; + + if (availableWidth < longestLabelWidth) { + rotate = 45; + if (availableWidth < longestLabelWidth * Math.cos(Math.PI / 4)) { + rotate = 90; + if (containerWidth < 14 * labelCount) { + interval = Math.ceil(labelCount / (containerWidth / 14)); + } + } + } + + return { rotate, interval }; +}; + const useLabelRotate = (antdTheme: any) => { const labelRotate = useCallback((chart, option) => { if (option?.xAxis?.axisLine?.show === false) { @@ -39,7 +74,7 @@ const useLabelRotate = (antdTheme: any) => { const font = `12px ${antdTheme.fontFamily}`; const yAxisLabelWidth = yAxisLabels.reduce((max, label) => { - const labelText = label.formattedLabel || label.rawLabel; + const labelText = getChartLabelText(label); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); context!.font = font; // Assume a 12px font. @@ -57,7 +92,7 @@ const useLabelRotate = (antdTheme: any) => { // Measure the longest label in pixels. const longestLabelWidth = labels.reduce((max, label) => { - const labelText = label.formattedLabel || label.rawLabel; + const labelText = getChartLabelText(label); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); context!.font = font; // Assume a 12px font. @@ -65,25 +100,7 @@ const useLabelRotate = (antdTheme: any) => { return Math.max(max, textWidth); }, 0); - // Calculate the available width for each label. - const availableWidth = containerWidth / labelCount; - - let rotate = 0; - let interval = 0; - - // Rotate labels when the available width is less than the longest label. - if (availableWidth < longestLabelWidth) { - rotate = 45; - // Check whether labels overlap at a 45-degree angle. - if (availableWidth < longestLabelWidth * Math.cos(Math.PI / 4)) { - rotate = 90; - // At 90 degrees, label height equals the longest label width. - if (containerWidth < 14 * labelCount) { - // Skip labels when the container is narrower than the rotated label height. - interval = Math.ceil(labelCount / (containerWidth / 14)); - } - } - } + const { rotate, interval } = calculateLabelRotation(containerWidth, labelCount, longestLabelWidth); _.set(option, 'xAxis.axisLabel.interval', interval); _.set(option, 'xAxis.axisLabel.rotate', rotate); chart.setOption(option, true); diff --git a/chat2db-community-client/src/blocks/BI/Chart/form/components/ComboAxisSelect/comboAxisSelect.test.ts b/chat2db-community-client/src/blocks/BI/Chart/form/components/ComboAxisSelect/comboAxisSelect.test.ts new file mode 100644 index 0000000000..3e3c321d28 --- /dev/null +++ b/chat2db-community-client/src/blocks/BI/Chart/form/components/ComboAxisSelect/comboAxisSelect.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { filterComboAxisActions } from './filterComboAxisActions'; + +interface Action { + key: string; +} + +const allActions: Action[] = [ + { key: 'move-up' }, + { key: 'move-down' }, + { key: 'delete-axis' }, +]; + +const actionKeys = (isFirst: boolean, isLast: boolean): string[] => { + return filterComboAxisActions(allActions, isFirst, isLast).map((action) => action.key); +}; + +assert.deepEqual(actionKeys(false, false), ['move-up', 'move-down', 'delete-axis']); +assert.deepEqual(actionKeys(true, false), ['move-down', 'delete-axis']); +assert.deepEqual(actionKeys(false, true), ['move-up', 'delete-axis']); +assert.deepEqual(actionKeys(true, true), ['delete-axis']); + +console.log('ComboAxisSelect action filter tests passed'); diff --git a/chat2db-community-client/src/blocks/BI/Chart/form/components/ComboAxisSelect/filterComboAxisActions.ts b/chat2db-community-client/src/blocks/BI/Chart/form/components/ComboAxisSelect/filterComboAxisActions.ts new file mode 100644 index 0000000000..6653ce5624 --- /dev/null +++ b/chat2db-community-client/src/blocks/BI/Chart/form/components/ComboAxisSelect/filterComboAxisActions.ts @@ -0,0 +1,15 @@ +interface ComboAxisAction { + key: string; +} + +export const filterComboAxisActions = ( + actions: readonly T[], + isFirst: boolean, + isLast: boolean, +): T[] => { + return actions.filter((action) => { + if (isFirst && action.key === 'move-up') return false; + if (isLast && action.key === 'move-down') return false; + return true; + }); +}; diff --git a/chat2db-community-client/src/blocks/BI/Chart/form/components/ComboAxisSelect/index.tsx b/chat2db-community-client/src/blocks/BI/Chart/form/components/ComboAxisSelect/index.tsx index 24c7790d71..5d7b4aa374 100644 --- a/chat2db-community-client/src/blocks/BI/Chart/form/components/ComboAxisSelect/index.tsx +++ b/chat2db-community-client/src/blocks/BI/Chart/form/components/ComboAxisSelect/index.tsx @@ -10,6 +10,7 @@ import { IComboYAxisDataItem } from '@/blocks/BI/Chart/typings'; import { ChartType } from '@/blocks/BI/Chart/constants'; import { useUpdateEffect } from 'ahooks'; import i18n from '@/i18n'; +import { filterComboAxisActions } from './filterComboAxisActions'; interface IProps { value?: IComboYAxisDataItem[]; @@ -122,7 +123,7 @@ const ComboAxisSelectItem = (props: IItemProps) => { }, []); const actionList = useMemo(() => { - const _list = [ + const allActions = [ { key: 'move-up', label: i18n('common.button.moveUp'), @@ -148,13 +149,7 @@ const ComboAxisSelectItem = (props: IItemProps) => { }, }, ]; - if (isFirst) { - _list.splice(0, 1); // Remove Move Up - } - if (isLast) { - _list.splice(1, 1); // Remove Move Down - } - return _list; + return filterComboAxisActions(allActions, isFirst, isLast); }, [isFirst, isLast]); return ( diff --git a/chat2db-community-client/src/blocks/BI/ChartCardBox/EditorChartSql/executionResult.test.ts b/chat2db-community-client/src/blocks/BI/ChartCardBox/EditorChartSql/executionResult.test.ts new file mode 100644 index 0000000000..9d47dc9ae4 --- /dev/null +++ b/chat2db-community-client/src/blocks/BI/ChartCardBox/EditorChartSql/executionResult.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { DatabaseTypeCode } from '@/constants/common'; +import { buildChartExecutionResult, type DatabaseInfoAndMetaData } from './executionResult'; + +const previousDatabaseInfo = { + dataSourceId: 1, + dataSourceName: 'previous-source', + databaseType: DatabaseTypeCode.MYSQL, + databaseName: 'previous_database', + schemaName: 'previous_schema', + sql: 'select old_value', +} as const; +const currentDatabaseInfo = { + dataSourceId: 2, + dataSourceName: 'current-source', + databaseType: DatabaseTypeCode.MYSQL, + databaseName: 'current_database', + schemaName: 'current_schema', + sql: 'select current_value', +} as const; +const populatedData = { + dataList: [[{ value: 'old-value' }]], + headerList: [{ name: 'old_column' }], +} as any; + +let savedResult: DatabaseInfoAndMetaData = buildChartExecutionResult({ + databaseInfo: previousDatabaseInfo, + data: [populatedData], +}); +savedResult = buildChartExecutionResult({ databaseInfo: currentDatabaseInfo, data: [] }); + +assert.deepEqual(savedResult.metaData?.dataList, []); +assert.deepEqual(savedResult.metaData?.headerList, []); +assert.deepEqual(savedResult.databaseInfo, currentDatabaseInfo); + +const firstEmptyResult = buildChartExecutionResult({ databaseInfo: currentDatabaseInfo, data: [] }); +assert.deepEqual(firstEmptyResult.metaData?.dataList, []); +assert.deepEqual(firstEmptyResult.metaData?.headerList, []); + +const currentData = { + dataList: [[{ value: 'current-value' }]], + headerList: [{ name: 'current_column' }], +} as any; +const populatedResult = buildChartExecutionResult({ + databaseInfo: currentDatabaseInfo, + data: [currentData], +}); +assert.equal(populatedResult.metaData?.dataList, currentData.dataList); +assert.equal(populatedResult.metaData?.headerList, currentData.headerList); +assert.deepEqual(populatedResult.databaseInfo, currentDatabaseInfo); + +console.log('BI chart SQL execution result tests passed'); diff --git a/chat2db-community-client/src/blocks/BI/ChartCardBox/EditorChartSql/executionResult.ts b/chat2db-community-client/src/blocks/BI/ChartCardBox/EditorChartSql/executionResult.ts new file mode 100644 index 0000000000..0b8eb4c71c --- /dev/null +++ b/chat2db-community-client/src/blocks/BI/ChartCardBox/EditorChartSql/executionResult.ts @@ -0,0 +1,24 @@ +import { IDatabaseBaseInfo, IManageResultData } from '@/typings/database'; +import { IChartItem } from '@/typings/dashboard'; + +export type DatabaseInfoAndMetaData = Pick; + +export interface ChartSqlExecutionCallbackParams { + databaseInfo: IDatabaseBaseInfo; + data?: Array>; +} + +export function buildChartExecutionResult({ + databaseInfo: { dataSourceId, dataSourceName, databaseType, databaseName, schemaName, sql }, + data = [], +}: ChartSqlExecutionCallbackParams): DatabaseInfoAndMetaData { + const result = data[0]; + + return { + databaseInfo: { dataSourceId, dataSourceName, databaseType, databaseName, schemaName, sql }, + metaData: { + dataList: result?.dataList ?? [], + headerList: result?.headerList ?? [], + } as IManageResultData, + }; +} diff --git a/chat2db-community-client/src/blocks/BI/ChartCardBox/EditorChartSql/index.tsx b/chat2db-community-client/src/blocks/BI/ChartCardBox/EditorChartSql/index.tsx index 629119b771..a39ac3adf2 100644 --- a/chat2db-community-client/src/blocks/BI/ChartCardBox/EditorChartSql/index.tsx +++ b/chat2db-community-client/src/blocks/BI/ChartCardBox/EditorChartSql/index.tsx @@ -1,26 +1,17 @@ -import { - memo, - useState, - useEffect, - useRef, - useMemo, - forwardRef, - useImperativeHandle, - ForwardedRef, -} from 'react'; +import { memo, useState, useEffect, useRef, useMemo, forwardRef, useImperativeHandle, ForwardedRef } from 'react'; import { useStyles } from './style'; import { IDatabaseBaseInfo } from '@/typings/database'; import { IChartItem } from '@/typings/dashboard'; import SQLExecute, { SQLExecuteRef } from '@/pages/main/workspace/components/SQLExecute'; import { WorkspaceTabType } from '@/constants'; -import { randomLargeLong } from '@/utils'; +import { getTemporaryId, randomLargeLong } from '@/utils'; +import { buildChartExecutionResult, type DatabaseInfoAndMetaData } from './executionResult'; export interface IProps { className?: string; chartDetail: IChartItem; } -type DatabaseInfoAndMetaData = Pick; export interface EditorChartSqlRef { getDatabaseInfoAndMetaData: () => DatabaseInfoAndMetaData; } @@ -66,28 +57,16 @@ const EditorChartSql = forwardRef((props: IProps, ref: ForwardedRef { return randomLargeLong(); }, []); + const workspaceTabId = useMemo(() => getTemporaryId(), []); const onExecuteSQLCallback = (params) => { - const { - databaseInfo: { dataSourceId, dataSourceName, databaseType, databaseName, schemaName, sql }, - data, - } = params; - - const { dataList, headerList } = data[0]; - - setDatabaseInfoAndMetaData({ - databaseInfo: { dataSourceId, dataSourceName, databaseType, databaseName, schemaName, sql }, - metaData: { - dataList, - headerList, - } as any, - }); + setDatabaseInfoAndMetaData(buildChartExecutionResult(params)); }; return (
= memo((props) => { const { styles } = useStyles(); const [resultDataList, setResultDataList] = useState(); - const { executeSQL } = useSqlExecutor(); + const { executeSQL, canExecuteSQL, executing } = useSqlExecutor(); const isResultVisible = true; const [openConfirm, setOpenConfirm] = useState(false); const handleExecuteSQL = useCallback(async () => { + if (!canExecuteSQL()) { + return; + } setResultDataList(null); const executeSqlParams = { @@ -64,7 +67,7 @@ const CodeBlock: FC = memo((props) => { setResultDataList(_resultDataList); setOpenConfirm(false); - }, [databaseInfo, codeContent]); + }, [canExecuteSQL, codeContent, databaseInfo, executeSQL]); // Mock function to check if confirmation is needed const checkNeedConfirm = async (sql: string) => { @@ -109,7 +112,7 @@ const CodeBlock: FC = memo((props) => { size={buttonSize} title={i18n('common.button.execute')} onClick={handleExecuteClick} - disabled={!databaseInfo?.dataSourceId} + disabled={!databaseInfo?.dataSourceId || executing} /> { sqlDatetimeSub: null, charOctetLength: null, ordinalPosition: null, - nullable: null, + nullable: NullableType.Null, generatedColumn: null, charSetName: null, collationName: null, diff --git a/chat2db-community-client/src/blocks/ERModal/ConsoleERModal.tsx b/chat2db-community-client/src/blocks/ERModal/ConsoleERModal.tsx index 405c5ed5b4..c5856d5b47 100644 --- a/chat2db-community-client/src/blocks/ERModal/ConsoleERModal.tsx +++ b/chat2db-community-client/src/blocks/ERModal/ConsoleERModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { beginLatestRequest, invalidateLatestRequest, isLatestRequest } from '@/utils/latestRequest'; import ERModal, { ERModalRef } from './index'; import { IBoundInfo } from '@/typings'; import { IERTableDetail } from '@/typings/er'; @@ -24,16 +25,22 @@ const ConsoleERModal = (props: IProps) => { const [loading, setLoading] = useState(true); const erModalRef = useRef(null); + const requestGenerationRef = useRef(0); useEffect(() => { + const requestGeneration = beginLatestRequest(requestGenerationRef); getTableErInfo({ dataSourceId, databaseName, schemaName }) .then((res) => { + if (!isLatestRequest(requestGenerationRef, requestGeneration)) return; setErModalData(res.tables); setStoredLayout(res.position); }) .finally(() => { - setLoading(false); + if (isLatestRequest(requestGenerationRef, requestGeneration)) setLoading(false); }); + return () => { + invalidateLatestRequest(requestGenerationRef); + }; }, []); const handleResetLayout = () => { diff --git a/chat2db-community-client/src/blocks/NewTree/components/TreeDropdown/index.tsx b/chat2db-community-client/src/blocks/NewTree/components/TreeDropdown/index.tsx index 7a9d4a7272..5aac3de347 100644 --- a/chat2db-community-client/src/blocks/NewTree/components/TreeDropdown/index.tsx +++ b/chat2db-community-client/src/blocks/NewTree/components/TreeDropdown/index.tsx @@ -2,9 +2,11 @@ import React, { useState, memo, useImperativeHandle, forwardRef, useMemo } from import { Dropdown } from 'antd'; import { TreeNodeData } from '@/typings'; import { OperationColumn } from '@/constants'; +import { ShortcutAction } from '@/constants/shortcut'; import { useCreateRightClickMenu, canBeDoubleClicked } from '../../hooks/useCreateRightClickMenu'; import { IconfontSvg } from '@chat2db/ui'; import { useTreeStore } from '@/store/tree'; +import ShortcutMenuLabel from '@/components/ShortcutMenuLabel'; interface IProps { className?: string; @@ -15,6 +17,7 @@ export interface TreeDropdownRef { setCurrentNode: (info: any) => void; // Returns true when this component handles the double-click. handleDoubleClick: (node: TreeNodeData) => boolean; + handleShortcut: (node: TreeNodeData, action: ShortcutAction) => boolean; } const TreeDropdown = (props: IProps, ref) => { @@ -45,6 +48,28 @@ const TreeDropdown = (props: IProps, ref) => { return false; }; + const executeShortcut = (items: ReturnType, action: ShortcutAction): boolean => { + for (const item of items) { + if (item.shortcutAction === action && item.onClick) { + item.onClick(); + return true; + } + if (item.children?.length && executeShortcut(item.children, action)) { + return true; + } + } + return false; + }; + + const handleShortcut = (node: TreeNodeData, action: ShortcutAction) => { + const menu = createRightClickMenu(node, specialHandleLoadData || handleLoadData); + const executed = executeShortcut(menu, action); + if (executed) { + setCurrentNode(null); + } + return executed; + }; + const renderChildren = (children: any) => { return children?.map((t) => { // dividing line @@ -57,7 +82,7 @@ const TreeDropdown = (props: IProps, ref) => { t.onClick?.(); }, icon: t.labelProps.icon && , - label: t.labelProps.label, + label: , children: renderChildren(t.children), }; }); @@ -85,6 +110,7 @@ const TreeDropdown = (props: IProps, ref) => { setCurrentNode, createRightClickMenu, handleDoubleClick, + handleShortcut, })); return ( diff --git a/chat2db-community-client/src/blocks/NewTree/functions/deleteTable.tsx b/chat2db-community-client/src/blocks/NewTree/functions/deleteTable.tsx index c01b90b788..45341ffa4a 100644 --- a/chat2db-community-client/src/blocks/NewTree/functions/deleteTable.tsx +++ b/chat2db-community-client/src/blocks/NewTree/functions/deleteTable.tsx @@ -2,8 +2,10 @@ import { useState } from 'react'; import mysqlService from '@/service/sql'; import { Button, Checkbox } from 'antd'; +import { staticMessage } from '@chat2db/ui'; import { openModal } from '@/store/common/components'; import styles from './deleteTable.less'; +import { getDeleteTableErrorMessage } from './deleteTableError'; import i18n from '@/i18n'; export const deleteTable = (treeNodeData, loadData) => { @@ -25,10 +27,15 @@ export const DeleteModalContent = (params: { treeNodeData: any; openModal: any; schemaName: treeNodeData.extraParams.schemaName, tableName: treeNodeData.originalTitle, }; - mysqlService.deleteTable(p).then(() => { - loadData(); - openModal(false); - }); + mysqlService + .deleteTable(p) + .then(() => { + loadData(); + openModal(false); + }) + .catch((error) => { + staticMessage.error(getDeleteTableErrorMessage(error, i18n('common.text.failure'))); + }); }; return ( diff --git a/chat2db-community-client/src/blocks/NewTree/functions/deleteTableError.test.ts b/chat2db-community-client/src/blocks/NewTree/functions/deleteTableError.test.ts new file mode 100644 index 0000000000..74ed5cc314 --- /dev/null +++ b/chat2db-community-client/src/blocks/NewTree/functions/deleteTableError.test.ts @@ -0,0 +1,18 @@ +import { getDeleteTableErrorMessage } from './deleteTableError'; + +function assertEqual(actual: string, expected: string, message: string) { + if (actual !== expected) { + throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +assertEqual( + getDeleteTableErrorMessage({ errorMessage: 'Table is referenced by a foreign key', message: 'generic' }, 'Failed'), + 'Table is referenced by a foreign key', + 'server errorMessage takes precedence', +); +assertEqual(getDeleteTableErrorMessage(new Error('Network unavailable'), 'Failed'), 'Network unavailable', 'Error.message is used'); +assertEqual(getDeleteTableErrorMessage('Request cancelled', 'Failed'), 'Request cancelled', 'string errors are used'); +assertEqual(getDeleteTableErrorMessage({ errorCode: 'SYSTEM_ERROR' }, 'Failed'), 'Failed', 'fallback is used'); + +console.log('delete table error message tests passed'); diff --git a/chat2db-community-client/src/blocks/NewTree/functions/deleteTableError.ts b/chat2db-community-client/src/blocks/NewTree/functions/deleteTableError.ts new file mode 100644 index 0000000000..864c3c4bd1 --- /dev/null +++ b/chat2db-community-client/src/blocks/NewTree/functions/deleteTableError.ts @@ -0,0 +1,17 @@ +export function getDeleteTableErrorMessage(error: unknown, fallback: string): string { + if (typeof error === 'string') { + return error || fallback; + } + if (!error || typeof error !== 'object') { + return fallback; + } + + const value = error as { errorMessage?: unknown; message?: unknown }; + if (typeof value.errorMessage === 'string' && value.errorMessage) { + return value.errorMessage; + } + if (typeof value.message === 'string' && value.message) { + return value.message; + } + return fallback; +} diff --git a/chat2db-community-client/src/blocks/NewTree/hooks/useCreateRightClickMenu.tsx b/chat2db-community-client/src/blocks/NewTree/hooks/useCreateRightClickMenu.tsx index b58323fddb..8a31344539 100644 --- a/chat2db-community-client/src/blocks/NewTree/hooks/useCreateRightClickMenu.tsx +++ b/chat2db-community-client/src/blocks/NewTree/hooks/useCreateRightClickMenu.tsx @@ -5,6 +5,7 @@ import { v4 as uuid } from 'uuid'; import { ConsoleOpenedStatus, OperationColumn, TreeNodeType, WorkspaceTabType, databaseTypeList } from '@/constants'; import { ImportExportType } from '@/constants/importExport'; import { OrgNavType } from '@/constants/organization'; +import { ShortcutAction } from '@/constants/shortcut'; import { TreeNodeData } from '@/typings'; import { canImportExport } from '@/utils/env'; @@ -61,6 +62,7 @@ import { emitSavedConsoleUpdated } from '@/utils/savedConsoleEvents'; interface IOperationColumnConfigItem { text: string; icon?: string; + shortcutAction?: ShortcutAction; doubleClickTrigger?: boolean; handle?: () => void; discard?: boolean; @@ -71,6 +73,7 @@ interface IRightClickMenu { key: number | string; onClick?: () => void; type: OperationColumn; + shortcutAction?: ShortcutAction; doubleClickTrigger?: boolean; labelProps: { icon?: string; @@ -223,7 +226,7 @@ export const useCreateRightClickMenu = () => { }; const refreshAfterDelete = () => { - const parentNode = getParentNode(treeNodeData.key, treeData!); + const parentNode = getParentNode(treeNodeData.key, treeData); handleLoadData(parentNode || treeNodeData, { refresh: true, }); @@ -699,6 +702,7 @@ export const useCreateRightClickMenu = () => { [OperationColumn.Refresh]: { text: i18n('common.button.refresh'), icon: 'icon-refresh', + shortcutAction: ShortcutAction.DatabaseTreeRefresh, handle: () => { handleLoadData(treeNodeData, { refresh: true, @@ -781,6 +785,7 @@ export const useCreateRightClickMenu = () => { [OperationColumn.CreateTable]: { text: i18n('editTable.button.createTable'), icon: 'icon-table-add', + shortcutAction: ShortcutAction.DatabaseTreeCreateTable, handle: () => { addWorkspaceTab({ id: uuid(), @@ -805,10 +810,12 @@ export const useCreateRightClickMenu = () => { icon: 'icon-trash', handle: () => { deleteTable(treeNodeData, () => { - const parentNode = getParentNode(treeNodeData.key, treeData!); - handleLoadData(parentNode, { - refresh: true, - }); + const parentNode = getParentNode(treeNodeData.key, treeData); + if (parentNode) { + handleLoadData(parentNode, { + refresh: true, + }); + } }); }, }, @@ -885,10 +892,12 @@ export const useCreateRightClickMenu = () => { handelPinTable({ treeNodeData, }).then(() => { - const parentNode = getParentNode(treeNodeData.key, treeData!); - handleLoadData(parentNode, { - refresh: true, - }); + const parentNode = getParentNode(treeNodeData.key, treeData); + if (parentNode) { + handleLoadData(parentNode, { + refresh: true, + }); + } }); }, }, @@ -897,6 +906,7 @@ export const useCreateRightClickMenu = () => { [OperationColumn.EditTable]: { text: i18n('workspace.menu.editTable'), icon: 'icon-table-edit', + shortcutAction: ShortcutAction.DatabaseTreeEditTable, handle: () => { const title = [tableName].filter(Boolean).join('.') + `[${dataSourceName}]`; const popoverContent = @@ -916,10 +926,12 @@ export const useCreateRightClickMenu = () => { uniqueData: { ...extraParams, submitCallback: () => { - const parentNode = getParentNode(treeNodeData.key, treeData!); - handleLoadData(parentNode, { - refresh: true, - }); + const parentNode = getParentNode(treeNodeData.key, treeData); + if (parentNode) { + handleLoadData(parentNode, { + refresh: true, + }); + } }, popoverContent, }, @@ -948,6 +960,7 @@ export const useCreateRightClickMenu = () => { [OperationColumn.OpenTable]: { text: i18n('workspace.menu.openTable'), icon: 'icon-table', + shortcutAction: ShortcutAction.DatabaseTreeOpenTable, doubleClickTrigger: true, handle: () => { const _tableName = compatibleDataBaseName(tableName!, databaseType!); @@ -1263,10 +1276,12 @@ export const useCreateRightClickMenu = () => { copyData: false, }) .then(() => { - const parentNode = getParentNode(treeNodeData.key, treeData!); - handleLoadData(parentNode, { - refresh: true, - }); + const parentNode = getParentNode(treeNodeData.key, treeData); + if (parentNode) { + handleLoadData(parentNode, { + refresh: true, + }); + } }); }, }, @@ -1282,10 +1297,12 @@ export const useCreateRightClickMenu = () => { copyData: true, }) .then(() => { - const parentNode = getParentNode(treeNodeData.key, treeData!); - handleLoadData(parentNode, { - refresh: true, - }); + const parentNode = getParentNode(treeNodeData.key, treeData); + if (parentNode) { + handleLoadData(parentNode, { + refresh: true, + }); + } }); }, }, @@ -1318,6 +1335,7 @@ export const useCreateRightClickMenu = () => { key: `${lastKey}-${i}`, onClick: t.handle, type, + shortcutAction: t.shortcutAction, labelProps: { icon: t.icon, label: t.text, @@ -1357,6 +1375,7 @@ export const useCreateRightClickMenu = () => { key: i, onClick: concrete?.handle, type: t, + shortcutAction: concrete.shortcutAction, doubleClickTrigger: concrete.doubleClickTrigger, labelProps: { icon: concrete?.icon, diff --git a/chat2db-community-client/src/blocks/NewTree/index.tsx b/chat2db-community-client/src/blocks/NewTree/index.tsx index eea9b0ae70..d18d72dadb 100644 --- a/chat2db-community-client/src/blocks/NewTree/index.tsx +++ b/chat2db-community-client/src/blocks/NewTree/index.tsx @@ -5,6 +5,12 @@ import { useStyles as renderTitleUseStyles } from './renderTitleStyle'; import { TreeNodeData } from '@/typings'; import { TreeNodeType } from '@/constants'; +import { + ShortcutAction, + ShortcutOverrides, + getEffectiveShortcutConfigMap, + isShortcutEventMatch, +} from '@/constants/shortcut'; import TreeDropdown, { TreeDropdownRef } from './components/TreeDropdown'; import ContextMenu, { ContextMenuRef } from '@/components/ContextMenu'; @@ -13,6 +19,7 @@ import TitleRender from './components/TitleRender'; import useTrimTreeData from './hooks/useTrimTreeData'; // import LoadingGracile from '@/components/Loading/LoadingGracile'; import { useTreeStore } from '@/store/tree'; +import { useGlobalStore } from '@/store/global'; import connectionService from '@/service/connection'; import { useSize } from 'ahooks'; @@ -26,6 +33,20 @@ interface IProps extends TreeProps { export interface NewTreeRef {} +const DATABASE_TREE_SHORTCUT_ACTIONS = [ + ShortcutAction.DatabaseTreeOpenTable, + ShortcutAction.DatabaseTreeEditTable, + ShortcutAction.DatabaseTreeCreateTable, + ShortcutAction.DatabaseTreeRefresh, +]; + +const isEditableTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) { + return false; + } + return !!target.closest('input, textarea, [contenteditable="true"], [contenteditable=""]'); +}; + const NewTree = (props: IProps, ref: React.ForwardedRef) => { void ref; const { className, leafNodes, hiddenNoPermission, excludeNodes, ...restProps } = props; @@ -64,6 +85,11 @@ const NewTree = (props: IProps, ref: React.ForwardedRef) => { scrollTargetKey: state.scrollTargetKey, setScrollTargetKey: state.setScrollTargetKey, })); + const shortcutOverrides = useGlobalStore((state) => state.shortcutOverrides); + const shortcutConfig = useMemo( + () => getEffectiveShortcutConfigMap(shortcutOverrides as ShortcutOverrides), + [shortcutOverrides], + ); useEffect(() => { if (treeRef.current) { @@ -190,6 +216,23 @@ const NewTree = (props: IProps, ref: React.ForwardedRef) => { ); }; + const handleDatabaseTreeShortcut = (event: React.KeyboardEvent) => { + const selectedTreeNode = findTreeNodeByKey(filteredTreeData, selectedKeys[0]); + if (!selectedTreeNode || isEditableTarget(event.target)) { + return; + } + + const action = DATABASE_TREE_SHORTCUT_ACTIONS.find((shortcutAction) => + isShortcutEventMatch(event, shortcutConfig[shortcutAction].binding), + ); + if (!action || !treeDropdownRef.current?.handleShortcut(selectedTreeNode, action)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + }; + const antdTreeProps: TreeProps = useMemo(() => { if (treeSize?.height) { lastTreeSize.current = treeSize; @@ -255,7 +298,12 @@ const NewTree = (props: IProps, ref: React.ForwardedRef) => { }, [selectedKeys, expandedKeys, treeSize?.height, editingTreeNode, filteredTreeData, restProps]); return ( -
+
{ + if (!treeData || targetKey === undefined) { + return undefined; + } + + for (const node of treeData) { + if (node.key === targetKey) { + return node; + } + const childNode = findTreeNodeByKey(node.children, targetKey); + if (childNode) { + return childNode; + } + } + return undefined; +}; diff --git a/chat2db-community-client/src/blocks/RedisAllData/CreateStream/index.tsx b/chat2db-community-client/src/blocks/RedisAllData/CreateStream/index.tsx index f10670a86f..aa00ea1076 100644 --- a/chat2db-community-client/src/blocks/RedisAllData/CreateStream/index.tsx +++ b/chat2db-community-client/src/blocks/RedisAllData/CreateStream/index.tsx @@ -14,6 +14,7 @@ import BaseTable, { BaseTableRef } from '@/components/BaseTable'; import { ToolbarBtn } from '@chat2db/ui'; import { Flex } from 'antd'; import { ActionType } from '@/constants/redis'; +import { mapDisplayedIndexToValueListIndex } from './mapDisplayedIndex'; // import { v4 as uuid } from 'uuid'; import lodash from 'lodash'; import { StreamValue } from '@/typings/redis'; @@ -173,9 +174,8 @@ const CreateStream = forwardRef((props: IProps, ref: ForwardedRef { - // Count deleted rows before this index. - const deleteCount = valueList.slice(0, index + 1).filter((item) => item.action === ActionType.DELETE).length; - const realIndex = index + deleteCount; + const realIndex = mapDisplayedIndexToValueListIndex(valueList, index); + if (realIndex === -1) return; setValueList((prevTableData) => { const newData = [...prevTableData]; newData[realIndex][columnName] = value; diff --git a/chat2db-community-client/src/blocks/RedisAllData/CreateStream/mapDisplayedIndex.test.ts b/chat2db-community-client/src/blocks/RedisAllData/CreateStream/mapDisplayedIndex.test.ts new file mode 100644 index 0000000000..0e9ab2e3e8 --- /dev/null +++ b/chat2db-community-client/src/blocks/RedisAllData/CreateStream/mapDisplayedIndex.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { ActionType } from '@/constants/redis'; +import { mapDisplayedIndexToValueListIndex } from './mapDisplayedIndex'; + +type Item = { action: ActionType; name: string }; + +const make = (action: ActionType, name: string): Item => ({ action, name }); + +// 1. No deleted rows — identity mapping +{ + const list = [make(ActionType.ORIGINAL, 'a'), make(ActionType.ORIGINAL, 'b'), make(ActionType.ORIGINAL, 'c')]; + assert.equal(mapDisplayedIndexToValueListIndex(list, 0), 0); + assert.equal(mapDisplayedIndexToValueListIndex(list, 1), 1); + assert.equal(mapDisplayedIndexToValueListIndex(list, 2), 2); + assert.equal(mapDisplayedIndexToValueListIndex(list, 3), -1); // out of range +} + +// 2. Deleted rows before the target — correct mapping +{ + // valueList: [DELETE, DELETE, A, B] + // displayed: [A(0), B(1)] + const list = [make(ActionType.DELETE, 'd1'), make(ActionType.DELETE, 'd2'), make(ActionType.ORIGINAL, 'A'), make(ActionType.ORIGINAL, 'B')]; + assert.equal(mapDisplayedIndexToValueListIndex(list, 0), 2, 'first visible (A) should map to index 2'); + assert.equal(mapDisplayedIndexToValueListIndex(list, 1), 3, 'second visible (B) should map to index 3'); + assert.equal(mapDisplayedIndexToValueListIndex(list, 2), -1, 'out of range'); +} + +// 3. Deleted rows between visible rows — correct mapping +{ + // valueList: [A, DELETE, B, DELETE, C] + // displayed: [A(0), B(1), C(2)] + const list = [make(ActionType.ORIGINAL, 'A'), make(ActionType.DELETE, 'd1'), make(ActionType.ORIGINAL, 'B'), make(ActionType.DELETE, 'd2'), make(ActionType.ORIGINAL, 'C')]; + assert.equal(mapDisplayedIndexToValueListIndex(list, 0), 0, 'A at index 0'); + assert.equal(mapDisplayedIndexToValueListIndex(list, 1), 2, 'B at index 2'); + assert.equal(mapDisplayedIndexToValueListIndex(list, 2), 4, 'C at index 4'); + assert.equal(mapDisplayedIndexToValueListIndex(list, 3), -1, 'out of range'); +} + +// 4. Mixed ADD/UPDATE/ORIGINAL rows — all non-DELETE are visible +{ + // valueList: [ADD, DELETE, UPDATE, ORIGINAL] + // displayed: [ADD(0), UPDATE(1), ORIGINAL(2)] + const list = [make(ActionType.ADD, 'new'), make(ActionType.DELETE, 'd'), make(ActionType.UPDATE, 'upd'), make(ActionType.ORIGINAL, 'orig')]; + assert.equal(mapDisplayedIndexToValueListIndex(list, 0), 0, 'ADD at index 0'); + assert.equal(mapDisplayedIndexToValueListIndex(list, 1), 2, 'UPDATE at index 2'); + assert.equal(mapDisplayedIndexToValueListIndex(list, 2), 3, 'ORIGINAL at index 3'); + assert.equal(mapDisplayedIndexToValueListIndex(list, 3), -1, 'out of range'); +} + +// 5. Out-of-range displayed index — returns -1 +{ + const list = [make(ActionType.ORIGINAL, 'a'), make(ActionType.DELETE, 'd'), make(ActionType.ORIGINAL, 'b')]; + assert.equal(mapDisplayedIndexToValueListIndex(list, 2), -1, 'only 2 visible rows, index 2 is out of range'); + assert.equal(mapDisplayedIndexToValueListIndex(list, -1), -1, 'negative index'); + assert.equal(mapDisplayedIndexToValueListIndex(list, 100), -1, 'large out-of-range index'); +} + +// 6. All rows deleted — returns -1 +{ + const list = [make(ActionType.DELETE, 'a'), make(ActionType.DELETE, 'b')]; + assert.equal(mapDisplayedIndexToValueListIndex(list, 0), -1, 'no visible rows'); +} + +// 7. Single row — edge case +{ + const list = [make(ActionType.ORIGINAL, 'only')]; + assert.equal(mapDisplayedIndexToValueListIndex(list, 0), 0, 'single visible row'); + assert.equal(mapDisplayedIndexToValueListIndex(list, 1), -1, 'out of range'); +} + +// 8. Empty list — returns -1 +{ + assert.equal(mapDisplayedIndexToValueListIndex([], 0), -1, 'empty list'); +} + +console.log('mapDisplayedIndex tests passed'); diff --git a/chat2db-community-client/src/blocks/RedisAllData/CreateStream/mapDisplayedIndex.ts b/chat2db-community-client/src/blocks/RedisAllData/CreateStream/mapDisplayedIndex.ts new file mode 100644 index 0000000000..1b3173db3a --- /dev/null +++ b/chat2db-community-client/src/blocks/RedisAllData/CreateStream/mapDisplayedIndex.ts @@ -0,0 +1,31 @@ +import { ActionType } from '@/constants/redis'; + +interface ValueListItem { + action: ActionType; + [key: string]: unknown; +} + +/** + * Maps a displayed (filtered) row index back to the valueList index. + * + * The displayed table filters out DELETE rows, so the displayed index + * must be mapped by counting non-DELETE rows (mirroring handleDelete's + * accumulation approach). + * + * @returns the valueList index, or -1 if the displayed index is out of range. + */ +export function mapDisplayedIndexToValueListIndex( + valueList: ValueListItem[], + displayedIndex: number, +): number { + let nonDeleteCount = 0; + for (let i = 0; i < valueList.length; i++) { + if (valueList[i].action !== ActionType.DELETE) { + if (nonDeleteCount === displayedIndex) { + return i; + } + nonDeleteCount++; + } + } + return -1; +} diff --git a/chat2db-community-client/src/blocks/SchemaSync/index.tsx b/chat2db-community-client/src/blocks/SchemaSync/index.tsx index e98a798404..642a1c71d5 100644 --- a/chat2db-community-client/src/blocks/SchemaSync/index.tsx +++ b/chat2db-community-client/src/blocks/SchemaSync/index.tsx @@ -40,7 +40,7 @@ const SchemaSync = forwardRef((props: IProps, ref: ForwardedRef) const [diffSql, setDiffSql] = useState(''); const [buttonLoading, setButtonLoading] = useState(false); const consoleId = useMemo(() => uuidv4(), []); - const { executeSQL } = useSqlExecutor(); + const { executeSQL, canExecuteSQL } = useSqlExecutor(); const [executeResult, setExecuteResult] = useState(); useImperativeHandle(ref, () => ({ @@ -73,6 +73,9 @@ const SchemaSync = forwardRef((props: IProps, ref: ForwardedRef) }; const handleSqlExecutor = () => { + if (!canExecuteSQL()) { + return; + } setButtonLoading(true); executeSQL({ sql: monacoEditorRef.current?.getValue() || '', diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSet/index.tsx b/chat2db-community-client/src/blocks/SearchResult/components/ResultSet/index.tsx index 5933671398..519faebd83 100644 --- a/chat2db-community-client/src/blocks/SearchResult/components/ResultSet/index.tsx +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSet/index.tsx @@ -49,7 +49,7 @@ export default memo( (props) => { const { viewTable } = props; const { styles, cx } = useStyles(); - const { executeSQL, stopExecuteSQL, executing } = useSqlExecutor(); + const { executeSQL, stopExecuteSQL, executing, canExecuteSQL } = useSqlExecutor(); const [resultData, setResultData] = useState(props.resultData); const resultSetToolbarRef = useRef(null); const screenResultRef = useRef(null); @@ -146,6 +146,7 @@ export default memo( // Only resultData changes here. Database metadata is stable, and the toolbar controls pagination. const handleExecuteSQL = useCallback( ({ pageNo: _pageNo }: { pageNo?: number } = {}) => { + if (!canExecuteSQL()) return; // Clear operation records resultSetTableRef.current?.operationRecordUtils?.clearOperationRecord?.(); // Do not execute before the result toolbar is mounted. @@ -181,7 +182,7 @@ export default memo( } }); }, - [resultData], + [canExecuteSQL, executeSQL, resultData, viewTable], ); const handleSearch = useCallback(() => { diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onChangeCellValue/index.test.ts b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onChangeCellValue/index.test.ts new file mode 100644 index 0000000000..c5b4f37146 --- /dev/null +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onChangeCellValue/index.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import onChangeCellValue from './index'; + +let listener: ((event: any) => void) | undefined; +let restoreCount = 0; +let trackedChangeCount = 0; +let largeValue = true; +let failRestore = false; + +const tableInstance = { + on: (_event: string, callback: (event: any) => void) => { + listener = callback; + return 1; + }, + getRecordByCell: () => ({ + CHAT2DB_ROW_NUMBER: 'row-1', + __CHAT2DB_CELL_META__: { 1: { largeValue } }, + }), + getHeaderField: () => 'value', + changeCellValue: (col: number, row: number) => { + restoreCount += 1; + if (failRestore) { + throw new Error('restore failed'); + } + assert.ok(listener); + listener({ col, row, currentValue: 'attempted edit', changedValue: 'original preview' }); + }, +}; + +onChangeCellValue(tableInstance as any, () => { + trackedChangeCount += 1; +}); + +assert.ok(listener); +listener({ col: 1, row: 1, currentValue: 'original preview', changedValue: 'attempted edit' }); +assert.equal(restoreCount, 1, 'the synthetic VTable event must not recursively restore the cell again'); +assert.equal(trackedChangeCount, 0, 'large-value preview restores must not be tracked as user edits'); + +failRestore = true; +assert.throws( + () => listener?.({ col: 1, row: 1, currentValue: 'original preview', changedValue: 'attempted edit' }), + /restore failed/, + 'restore failures should remain observable', +); +failRestore = false; +largeValue = false; +listener({ col: 1, row: 1, currentValue: 'before', changedValue: 'after' }); +assert.equal(trackedChangeCount, 1, 'the guard should reset after a failure so ordinary edits are still tracked'); + +console.log('onChangeCellValue tests passed'); diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onChangeCellValue/index.tsx b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onChangeCellValue/index.tsx index 8f9607d33b..fb97518f36 100644 --- a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onChangeCellValue/index.tsx +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onChangeCellValue/index.tsx @@ -1,15 +1,27 @@ import * as VTable from '@visactor/vtable'; const onChangeCellValue = (tableInstance: VTable.ListTable, handleCellValueChange) => { + // Reentry guard: when a large-value cell is edited, the listener programmatically + // calls changeCellValue to restore the original display. VTable re-fires + // change_cell_value synchronously inside that call (because oldValue !== changedValue), + // which would re-enter this listener and oscillate forever. The flag suppresses the + // synthetic re-fire; it is only set during our own programmatic restore, so real user + // edits are unaffected. + let isRestoring = false; const id = tableInstance.on('change_cell_value', (event) => { - if (event.currentValue === event.changedValue) { + if (isRestoring || event.currentValue === event.changedValue) { return; } const { row, col } = event; const originData = tableInstance.getRecordByCell(col, row); const cellMeta = originData?.__CHAT2DB_CELL_META__?.[col]; if (cellMeta?.largeValue) { - tableInstance.changeCellValue(col, row, event.currentValue); + isRestoring = true; + try { + tableInstance.changeCellValue(col, row, event.currentValue); + } finally { + isRestoring = false; + } return; } const headerField = tableInstance.getHeaderField(col, row); diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/hooks/useOperationRecord.test.tsx b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/hooks/useOperationRecord.test.tsx new file mode 100644 index 0000000000..023c002c74 --- /dev/null +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/hooks/useOperationRecord.test.tsx @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import useOperationRecord, { OperationRecordUtils } from './useOperationRecord'; + +const createChangeRecord = (field: string, currentValue: string, changedValue: string, rowId = 'row-1') => ({ + field, + rowId, + rawValue: currentValue, + currentValue, + changedValue, +}); + +const renderOperationRecord = (tableInstance: any) => { + let operationRecordUtils: OperationRecordUtils | undefined; + + const HookHarness = () => { + operationRecordUtils = useOperationRecord({ + tableInstance, + theme: {} as any, + }).operationRecordUtils; + return null; + }; + + renderToStaticMarkup(createElement(HookHarness)); + assert.ok(operationRecordUtils); + return operationRecordUtils; +}; + +function testNoSelectionRevertKeepsTrackingEnabled() { + const row = { + CHAT2DB_ROW_NUMBER: 'row-1', + 1: 'before', + }; + const operationRecordUtils = renderOperationRecord({ + columns: [{ field: '1' }], + records: [row], + dataSource: { currentIndexedData: [0] }, + getSelectedCellInfos: () => [], + clearSelected: () => undefined, + }); + + operationRecordUtils.handleRevocation(); + operationRecordUtils.handleCellValueChange(createChangeRecord('1', 'before', 'after')); + + assert.deepEqual(operationRecordUtils.getOperationChangeDetail(), [ + { + rowId: 'row-1', + type: 'UPDATE', + dataList: { + ...row, + 1: 'after', + }, + oldDataList: row, + }, + ]); +} + +function testMultiCellRevertDoesNotRecordRestoreCallbacks() { + const row = { + CHAT2DB_ROW_NUMBER: 'row-1', + 1: 'before-1', + 2: 'before-2', + }; + const operationRecordUtilsRef: { current?: OperationRecordUtils } = {}; + let restoreCallbackCount = 0; + const tableInstance = { + columns: [{ field: '1' }, { field: '2' }], + records: [row], + dataSource: { currentIndexedData: [0] }, + getSelectedCellInfos: () => [ + [ + { field: '1', originData: row }, + { field: '2', originData: row }, + ], + ], + arrangeCustomCellStyle: () => undefined, + changeCellValue: (col: number, _row: number, value: string) => { + restoreCallbackCount += 1; + const field = String(col); + assert.ok(operationRecordUtilsRef.current); + operationRecordUtilsRef.current.handleCellValueChange(createChangeRecord(field, `after-${field}`, value)); + }, + clearSelected: () => undefined, + }; + const operationRecordUtils = renderOperationRecord(tableInstance); + operationRecordUtilsRef.current = operationRecordUtils; + operationRecordUtils.handleCellValueChange(createChangeRecord('1', 'before-1', 'after-1')); + operationRecordUtils.handleCellValueChange(createChangeRecord('2', 'before-2', 'after-2')); + + operationRecordUtils.handleRevocation(); + + assert.equal(restoreCallbackCount, 2); + assert.deepEqual(operationRecordUtils.getOperationChangeDetail(), []); +} + +function testRevertFailureRestoresTracking() { + const row = { + CHAT2DB_ROW_NUMBER: 'row-1', + 1: 'before-1', + 2: 'before-2', + }; + let shouldThrow = true; + const tableInstance = { + columns: [{ field: '1' }, { field: '2' }], + records: [row], + dataSource: { currentIndexedData: [0] }, + getSelectedCellInfos: () => [[{ field: '1', originData: row }]], + arrangeCustomCellStyle: () => undefined, + changeCellValue: () => { + if (shouldThrow) { + throw new Error('restore failed'); + } + }, + clearSelected: () => undefined, + }; + const operationRecordUtils = renderOperationRecord(tableInstance); + operationRecordUtils.handleCellValueChange(createChangeRecord('1', 'before-1', 'after-1')); + + assert.throws(() => operationRecordUtils.handleRevocation(), /restore failed/); + shouldThrow = false; + operationRecordUtils.handleCellValueChange(createChangeRecord('2', 'before-2', 'after-2')); + + const operations = operationRecordUtils.getOperationChangeDetail(); + assert.equal(operations.length, 1); + assert.equal(operations[0].dataList[2], 'after-2'); +} + +function testNestedPauseStaysActiveUntilRevertCompletes() { + const row = { + CHAT2DB_ROW_NUMBER: 'row-1', + 1: 'before-1', + 2: 'before-2', + }; + const operationRecordUtilsRef: { current?: OperationRecordUtils } = {}; + let recordedUnexpectedUpdate = false; + const tableInstance = { + columns: [{ field: '1' }, { field: '2' }], + records: [row], + dataSource: { currentIndexedData: [0] }, + getSelectedCellInfos: () => [[{ field: '1', originData: row }]], + arrangeCustomCellStyle: () => undefined, + changeCellValue: () => undefined, + getRecordByCell: () => { + assert.ok(operationRecordUtilsRef.current); + operationRecordUtilsRef.current.handleCellValueChange(createChangeRecord('2', 'before-2', 'unexpected')); + recordedUnexpectedUpdate = operationRecordUtilsRef.current + .getOperationChangeDetail() + .some((operation) => operation.dataList?.[2] === 'unexpected'); + return row; + }, + clearSelected: () => undefined, + }; + const operationRecordUtils = renderOperationRecord(tableInstance); + operationRecordUtilsRef.current = operationRecordUtils; + operationRecordUtils.handleCellValueChange({ + ...createChangeRecord('1', 'before-1', 'after-1'), + restoreCellMeta: {} as any, + }); + + operationRecordUtils.handleRevocation(); + + assert.equal(recordedUnexpectedUpdate, false); + assert.deepEqual(operationRecordUtils.getOperationChangeDetail(), []); +} + +function testFailedCreateRowRevertKeepsCreateRecord() { + const newRow = { + CHAT2DB_ROW_NUMBER: 'new-row', + 1: null, + }; + const records: any[] = []; + const dataSource = { currentIndexedData: [] as number[] }; + const tableInstance = { + columns: [{ field: '1' }], + records, + dataSource, + getSelectedCellInfos: () => [[{ field: '1', originData: newRow }]], + arrangeCustomCellStyle: () => undefined, + deleteRecords: () => { + throw new Error('delete failed'); + }, + addRecords: (newRecords: any[]) => { + records.push(...newRecords); + dataSource.currentIndexedData = records.map((_, index) => index); + }, + scrollToCell: () => undefined, + clearSelected: () => undefined, + }; + const operationRecordUtils = renderOperationRecord(tableInstance); + operationRecordUtils.handleAddBlankRow(newRow, 'new-row'); + + assert.throws(() => operationRecordUtils.handleRevocation(), /delete failed/); + assert.equal(operationRecordUtils.isCreateRow('new-row'), true); +} + +function testDeleteFailureAfterPhysicalRemovalKeepsBookkeepingConsistent() { + const row = { + CHAT2DB_ROW_NUMBER: 'row-1', + 1: 'before-1', + }; + const newRow = { + CHAT2DB_ROW_NUMBER: 'new-row', + 1: null, + }; + const records: any[] = [row]; + const dataSource = { currentIndexedData: [0] }; + const tableInstance = { + columns: [{ field: '1' }], + records, + dataSource, + getSelectedCellInfos: () => [ + [{ field: '1', originData: newRow, row: 2 }], + [{ field: '1', originData: row, row: 1 }], + ], + arrangeCustomCellStyle: () => undefined, + changeCellValue: () => { + throw new Error('restore failed'); + }, + deleteRecords: () => undefined, + addRecords: (newRecords: any[]) => { + records.push(...newRecords); + dataSource.currentIndexedData = records.map((_, index) => index); + }, + scrollToCell: () => undefined, + clearSelected: () => undefined, + }; + const operationRecordUtils = renderOperationRecord(tableInstance); + operationRecordUtils.handleAddBlankRow(newRow, 'new-row'); + operationRecordUtils.handleCellValueChange(createChangeRecord('1', 'before-1', 'after-1')); + + assert.throws(() => operationRecordUtils.handleDeleteRow(), /restore failed/); + assert.equal(operationRecordUtils.isCreateRow('new-row'), false); + assert.deepEqual( + operationRecordUtils.getOperationChangeDetail().map((operation) => operation.type), + ['DELETE'], + ); +} + +testNoSelectionRevertKeepsTrackingEnabled(); +testMultiCellRevertDoesNotRecordRestoreCallbacks(); +testRevertFailureRestoresTracking(); +testNestedPauseStaysActiveUntilRevertCompletes(); +testFailedCreateRowRevertKeepsCreateRecord(); +testDeleteFailureAfterPhysicalRemovalKeepsBookkeepingConsistent(); + +console.log('useOperationRecord tests passed'); diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/hooks/useOperationRecord.tsx b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/hooks/useOperationRecord.tsx index 453e08ba7c..a381770164 100644 --- a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/hooks/useOperationRecord.tsx +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/hooks/useOperationRecord.tsx @@ -48,6 +48,16 @@ const useOperationRecord: IUseOperationRecord = ({ tableInstance, theme }) => { const createRowRecordListRef = useRef(createRowRecordList); const deleteRowRecordListRef = useRef(deleteRowRecordList); + const runWithCellValueTrackingPaused = useCallback((operation: () => void) => { + const previousValue = isListenCellValueChange.current; + isListenCellValueChange.current = false; + try { + operation(); + } finally { + isListenCellValueChange.current = previousValue; + } + }, []); + // callback for monitoring cell value changes const handleCellValueChange = useCallback( (record: ICellChangeRecord) => { @@ -157,62 +167,61 @@ const useOperationRecord: IUseOperationRecord = ({ tableInstance, theme }) => { // handles undo operations const handleRevocation = useCallback(() => { if (!tableInstance) return; - // does not monitor cell value changes when undoing operations - isListenCellValueChange.current = false; const cells = tableInstance.getSelectedCellInfos() || []; if (cells.length === 0) return; - const _createRowRecordList = createRowRecordListRef.current; - const _deleteRowRecordList = deleteRowRecordListRef.current; - const _cellChangeRecordList = cellChangeRecordLisLRef.current; - // records the rows that need to be deleted - const deleteRows: string[] = []; - - cells.forEach((cell) => { - const rowId: string = cell?.[0]?.originData?.CHAT2DB_ROW_NUMBER; - if (rowId === undefined) return; - // Determines whether the current row is a new row or a deleted row, and performs row-level undo - const createIndex = _createRowRecordList.findIndex((item) => item === rowId); - const deleteIndex = _deleteRowRecordList.findIndex((item) => item === rowId); - if (createIndex > -1 || deleteIndex > -1) { - if (createIndex > -1) { - _createRowRecordList.splice(createIndex, 1); - // If the undoing of a new row requires deleting the new row - deleteRows.push(rowId); - } else { - _deleteRowRecordList.splice(deleteIndex, 1); - } - handleRevocationRowStyle(rowId); - return; - } - // Perform cell-level undo - cell.forEach((item) => { - _cellChangeRecordList.forEach((record, index) => { - if (record.field === item.field && record.rowId === item.originData.CHAT2DB_ROW_NUMBER) { - handleRevocationCellStyle({ - field: record.field, - rowId: record.rowId, - value: record.restoreValue !== undefined ? record.restoreValue : record.currentValue, - cellMeta: record.restoreCellMeta, - }); - - _cellChangeRecordList.splice(index, 1); + runWithCellValueTrackingPaused(() => { + const _createRowRecordList = [...createRowRecordListRef.current]; + const _deleteRowRecordList = [...deleteRowRecordListRef.current]; + const _cellChangeRecordList = [...cellChangeRecordLisLRef.current]; + // records the rows that need to be deleted + const deleteRows: string[] = []; + + cells.forEach((cell) => { + const rowId: string = cell?.[0]?.originData?.CHAT2DB_ROW_NUMBER; + if (rowId === undefined) return; + // Determines whether the current row is a new row or a deleted row, and performs row-level undo + const createIndex = _createRowRecordList.findIndex((item) => item === rowId); + const deleteIndex = _deleteRowRecordList.findIndex((item) => item === rowId); + if (createIndex > -1 || deleteIndex > -1) { + if (createIndex > -1) { + _createRowRecordList.splice(createIndex, 1); + // If the undoing of a new row requires deleting the new row + deleteRows.push(rowId); + } else { + _deleteRowRecordList.splice(deleteIndex, 1); } + handleRevocationRowStyle(rowId); + return; + } + // Perform cell-level undo + cell.forEach((item) => { + _cellChangeRecordList.forEach((record, index) => { + if (record.field === item.field && record.rowId === item.originData.CHAT2DB_ROW_NUMBER) { + handleRevocationCellStyle({ + field: record.field, + rowId: record.rowId, + value: record.restoreValue !== undefined ? record.restoreValue : record.currentValue, + cellMeta: record.restoreCellMeta, + }); + + _cellChangeRecordList.splice(index, 1); + } + }); }); }); + if (deleteRows.length > 0) { + // What needs to be passed in here is not the line number, but the line number-1 + const rowNumbers = findRowNumbersByIds(tableInstance, deleteRows).map((row) => row - 1); + tableInstance.deleteRecords(rowNumbers); + } + cellChangeRecordLisLRef.current = [..._cellChangeRecordList]; + createRowRecordListRef.current = [..._createRowRecordList]; + deleteRowRecordListRef.current = [..._deleteRowRecordList]; + setCellChangeRecordList(cellChangeRecordLisLRef.current); + setCreateRowRecordList(createRowRecordListRef.current); + setDeleteRowRecordList(deleteRowRecordListRef.current); }); - if (deleteRows.length > 0) { - // What needs to be passed in here is not the line number, but the line number-1 - const rowNumbers = findRowNumbersByIds(tableInstance, deleteRows).map((row) => row - 1); - tableInstance.deleteRecords(rowNumbers); - } - cellChangeRecordLisLRef.current = [..._cellChangeRecordList]; - createRowRecordListRef.current = [..._createRowRecordList]; - deleteRowRecordListRef.current = [..._deleteRowRecordList]; - setCellChangeRecordList(cellChangeRecordLisLRef.current); - setCreateRowRecordList(createRowRecordListRef.current); - setDeleteRowRecordList(deleteRowRecordListRef.current); - isListenCellValueChange.current = true; - }, [tableInstance]); + }, [tableInstance, runWithCellValueTrackingPaused]); // Delete row const handleDeleteRow = useCallback(() => { @@ -245,37 +254,26 @@ const useOperationRecord: IUseOperationRecord = ({ tableInstance, theme }) => { tableInstance.deleteRecords(rowsToDeleteRows); } - isListenCellValueChange.current = false; - - let _cellChangeRecordList = cellChangeRecordLisLRef.current; + const deletedRowIds = new Set(curDeleteRows); + const revokedCellRecords = cellChangeRecordLisLRef.current.filter((record) => deletedRowIds.has(record.rowId)); + const remainingCellRecords = cellChangeRecordLisLRef.current.filter((record) => !deletedRowIds.has(record.rowId)); + handleDeleteRowRecord(curDeleteRows); + if (revokedCellRecords.length > 0) { + cellChangeRecordLisLRef.current = remainingCellRecords; + setCellChangeRecordList(remainingCellRecords); + } - // If there are edited cells in the deleted row, you need to undo the styles of these cells and record them. - cells.forEach((cell) => { - const rowId = cell?.[0]?.originData?.CHAT2DB_ROW_NUMBER; - if (rowId === undefined) return; - _cellChangeRecordList = _cellChangeRecordList.filter((_item) => { - const flag = _item.rowId === rowId; - if (flag) { - handleRevocationCellStyle({ - field: _item.field, - rowId: _item.rowId, - value: _item.restoreValue !== undefined ? _item.restoreValue : _item.currentValue, - cellMeta: _item.restoreCellMeta, - }); - } - return !flag; + runWithCellValueTrackingPaused(() => { + revokedCellRecords.forEach((_item) => { + handleRevocationCellStyle({ + field: _item.field, + rowId: _item.rowId, + value: _item.restoreValue !== undefined ? _item.restoreValue : _item.currentValue, + cellMeta: _item.restoreCellMeta, + }); }); }); - // If the deleted row contains modified cells, these records need to be deleted - if (_cellChangeRecordList.length !== cellChangeRecordLisLRef.current.length) { - cellChangeRecordLisLRef.current = _cellChangeRecordList; - setCellChangeRecordList(_cellChangeRecordList); - } - - isListenCellValueChange.current = true; - - handleDeleteRowRecord(curDeleteRows); - }, [tableInstance]); + }, [tableInstance, runWithCellValueTrackingPaused]); // clone line const handleCloneRow = useCallback(() => { @@ -355,10 +353,10 @@ const useOperationRecord: IUseOperationRecord = ({ tableInstance, theme }) => { '', ); if (value !== undefined) { - isListenCellValueChange.current = false; - // Restore the value of the cell - tableInstance.changeCellValue(colNumber, rowNumber, value); - isListenCellValueChange.current = true; + runWithCellValueTrackingPaused(() => { + // Restore the value of the cell + tableInstance.changeCellValue(colNumber, rowNumber, value); + }); } if (cellMeta) { const originData = tableInstance.getRecordByCell(colNumber, rowNumber); @@ -368,7 +366,7 @@ const useOperationRecord: IUseOperationRecord = ({ tableInstance, theme }) => { } } }, - [tableInstance], + [tableInstance, runWithCellValueTrackingPaused], ); // Undo the style of the current row @@ -431,7 +429,9 @@ const useOperationRecord: IUseOperationRecord = ({ tableInstance, theme }) => { // TODO: There is a problem here, only as of VTable version 1.10.0 // bug description: Change the color of a cell to A, then to B, then to A. At this time, the cell has no color. // Resetting this undocumented VTable arrangement fixes update colors that cycle A -> B -> A. - // Registered create/delete styles remain intact after the reset. + // Registered create/delete *style definitions* remain intact after the reset, but the arrangement + // (cell -> style) mappings are cleared. Re-apply create/delete arrangements below so a plain cell + // edit does not wipe green (new-row) / red (deleted-row) highlights until the next sort/filter. if (tableInstance?.customCellStylePlugin) { tableInstance.customCellStylePlugin.customCellStyleArrangement = []; } @@ -448,7 +448,20 @@ const useOperationRecord: IUseOperationRecord = ({ tableInstance, theme }) => { 'custom-update-cell', ); }); - }, [cellChangeRecordList, tableInstance]); + + findRowNumbersByIds(tableInstance, createRowRecordList).forEach((row) => { + tableInstance?.arrangeCustomCellStyle( + { range: { start: { row, col: 0 }, end: { row, col: columns.length } } }, + 'custom-create-cell', + ); + }); + findRowNumbersByIds(tableInstance, deleteRowRecordList)?.forEach((row) => { + tableInstance?.arrangeCustomCellStyle( + { range: { start: { row, col: 0 }, end: { row, col: columns.length } } }, + 'custom-delete-cell', + ); + }); + }, [cellChangeRecordList, createRowRecordList, deleteRowRecordList, columns, tableInstance]); useEffect(() => { if (!tableInstance) return; diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetToolbar/index.tsx b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetToolbar/index.tsx index 6ce30d6ae5..b8220b682e 100644 --- a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetToolbar/index.tsx +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetToolbar/index.tsx @@ -11,6 +11,7 @@ import sqlService from '@/service/sql'; import _ from 'lodash'; import EditorChartModal, { EditChartModalRef } from '@/blocks/BI/ChartCardBox/EditorChartModal'; import DingChartModal, { DingChartModalRef } from '@/blocks/BI/ChartCardBox/DingChartModal'; +import ChartNoAxesCombined from '@/components/LucideIcons/ChartNoAxesCombined'; import { useZoerStore } from '@/store/zoer'; import { useGlobalStore } from '@/store/global'; @@ -214,7 +215,7 @@ const ResultSetToolbar = forwardRef((props: IProps, ref: ForwardedRef { diff --git a/chat2db-community-client/src/blocks/SearchResult/components/SQLPreviewExecute/index.tsx b/chat2db-community-client/src/blocks/SearchResult/components/SQLPreviewExecute/index.tsx index 517b22a897..e202c19b5d 100644 --- a/chat2db-community-client/src/blocks/SearchResult/components/SQLPreviewExecute/index.tsx +++ b/chat2db-community-client/src/blocks/SearchResult/components/SQLPreviewExecute/index.tsx @@ -25,7 +25,8 @@ const SQLPreviewExecute = forwardRef((props: IProps, ref: ForwardedRef(null); const [executeSqlParams, setExecuteSqlParams] = useState(null); const executeSQLRef = useRef(); - const { executeSQL } = useSqlExecutor(); + const executePendingRef = useRef(false); + const { executeSQL, canExecuteSQL } = useSqlExecutor(); const getUpdateDataSql = ({ operations, resultData }) => { if (!operations.length) { @@ -51,28 +52,28 @@ const SQLPreviewExecute = forwardRef((props: IProps, ref: ForwardedRef { + if (executePendingRef.current || !canExecuteSQL()) { + return; + } + executePendingRef.current = true; callback?.(true); - getUpdateDataSql({ operations, resultData }) - .then((res) => { - executeSQL(res) - .then((_res) => { - if (_res?.[0]?.success === true) { - onExecuteSuccess?.(); - setViewUpdateDataSqlModal(false); - setUpdateDataSql(''); - } else { - onExecuteError?.(_res?.[0]?.message || ''); - } - }) - .catch((error) => { - onExecuteError?.(getRequestErrorMessage(error)); - }) - .finally(() => { - callback?.(false); - }); + Promise.resolve() + .then(() => getUpdateDataSql({ operations, resultData })) + .then((res) => executeSQL(res)) + .then((_res) => { + if (_res?.[0]?.success === true) { + onExecuteSuccess?.(); + setViewUpdateDataSqlModal(false); + setUpdateDataSql(''); + } else { + onExecuteError?.(_res?.[0]?.message || ''); + } }) .catch((error) => { onExecuteError?.(getRequestErrorMessage(error)); + }) + .finally(() => { + executePendingRef.current = false; callback?.(false); }); }; diff --git a/chat2db-community-client/src/blocks/SearchResult/index.tsx b/chat2db-community-client/src/blocks/SearchResult/index.tsx index 4319483796..6bccc814f3 100644 --- a/chat2db-community-client/src/blocks/SearchResult/index.tsx +++ b/chat2db-community-client/src/blocks/SearchResult/index.tsx @@ -11,28 +11,41 @@ import { useRef, } from 'react'; import classnames from 'classnames'; +import { Dropdown, type MenuProps } from 'antd'; +import { ArrowDownUp, Check, SlidersHorizontal } from 'lucide-react'; import CustomTabs, { ITabItem } from '@/components/Tabs'; -import Iconfont from '@/components/Iconfont'; import { IManageResultData } from '@/typings'; import SearchResultItem from './components/SearchResultItem'; import Abstract from '@/components/Abstract'; import i18n from '@/i18n'; import { useStyles } from './style'; -import { Empty, EmptyImage, IconfontSvg } from '@chat2db/ui'; +import { Empty, EmptyImage, IconButton, IconfontSvg } from '@chat2db/ui'; import SQLPreview from '@/components/SQLPreview'; import ExecutionConsole from './components/ExecutionConsole'; import ExecutionMessages, { IExecutionMessageItem } from './components/ExecutionMessages'; import type { SqlExecutionLogRecord } from '@/service/sqlExecutionLog'; +import type { SqlExecutionResultIdentity } from '@/service/sqlExecutionStream'; +import { + createResultTabOrderStorageKey, + getResultTabPreferenceStorage, + orderExecutionResultsByBatch, + persistResultTabOrder, + readResultTabOrder, + subscribeResultTabOrder, + type ResultTabOrder, +} from './resultTabPreferences'; import { ABSTRACT_TAB_ID, CONSOLE_TAB_ID, MESSAGES_TAB_ID, + getConsoleResultTabLabel, getPreferredActiveTabId, getResultIdentity, hasLegacyResultTab, hasTabularResult, reduceActiveTabSelection, } from './tabSelection'; +import { ShortcutAction } from '@/constants/shortcut'; interface IProps { className?: string; @@ -42,15 +55,22 @@ interface IProps { resultBatchKey?: number; forceOutputTab?: boolean; keepExecutionLogHistory?: boolean; + keepResultHistory?: boolean; + showExecutionResultCoordinates?: boolean; + closeActiveResultShortcutEnabled?: boolean; viewTable?: boolean; onClearExecutionLog?: () => void; onKeepExecutionLogHistoryChange?: (keepHistory: boolean) => void; + onKeepResultHistoryChange?: (keepHistory: boolean) => void; onResultDataListChange?: (params: { resultDataList: IManageResultData[]; historyResultDataList: IManageResultData[]; + closedResultIdentities: SqlExecutionResultIdentity[]; }) => void; } +const RESULT_TAB_ORDER_STORAGE_KEY = createResultTabOrderStorageKey('community', __RUNTIME_ENV__); + export interface ISearchResultRef { handleDemo: () => void; } @@ -83,6 +103,14 @@ function getLatestTerminalLogVersion(records?: SqlExecutionLogRecord[]) { return undefined; } +function getSqlExecutionResultIdentity(result: IManageResultData): SqlExecutionResultIdentity | undefined { + const executionId = result.extra?.executionId; + const resultKey = result.extra?.resultKey; + return typeof executionId === 'string' && executionId && typeof resultKey === 'string' && resultKey + ? { executionId, resultKey } + : undefined; +} + const SearchResult = forwardRef((props: IProps, ref: ForwardedRef) => { const { className, viewTable = false } = props; const consoleMode = props.executionLogRecords !== undefined; @@ -92,14 +120,26 @@ const SearchResult = forwardRef((props: IProps, ref: ForwardedRef [...(resultDataList || []), ...historyResultDataList], + [resultDataList, historyResultDataList], + ); + const [resultTabOrder, setResultTabOrder] = useState(() => + readResultTabOrder(getResultTabPreferenceStorage(), RESULT_TAB_ORDER_STORAGE_KEY), + ); + const orderedConsoleResultDataList = useMemo( + () => orderExecutionResultsByBatch(consoleResultDataList, resultTabOrder), + [consoleResultDataList, resultTabOrder], + ); const [tabSelection, dispatchTabSelection] = useReducer(reduceActiveTabSelection, { activeTabId: getPreferredActiveTabId(props.resultDataList[props.resultDataList.length - 1], consoleMode), + followPreferredTabs: true, }); const activeTabId = tabSelection.activeTabId; const knownResultVersionMapRef = useRef>(new Map()); const latestTerminalLogVersion = getLatestTerminalLogVersion(props.executionLogRecords); const visibleHistoryResultDataList = useMemo( - () => (consoleMode ? historyResultDataList.filter(hasTabularResult) : historyResultDataList), + () => (consoleMode ? [] : historyResultDataList), [consoleMode, historyResultDataList], ); @@ -107,10 +147,24 @@ const SearchResult = forwardRef((props: IProps, ref: ForwardedRef {}, })); + useEffect( + () => + subscribeResultTabOrder((storageKey, order) => { + if (storageKey === RESULT_TAB_ORDER_STORAGE_KEY) { + setResultTabOrder(order); + } + }), + [], + ); + useEffect(() => { - dispatchTabSelection({ type: 'resetPreference' }); - setShowHistory(false); - }, [props.resultBatchKey]); + if (consoleMode) { + dispatchTabSelection({ type: 'startAutoFollow', tabId: CONSOLE_TAB_ID }); + } else { + dispatchTabSelection({ type: 'resetPreference' }); + setShowHistory(false); + } + }, [props.resultBatchKey, consoleMode]); useEffect(() => { const nextResultDataList = props.resultDataList || []; @@ -152,37 +206,36 @@ const SearchResult = forwardRef((props: IProps, ref: ForwardedRef { if (consoleMode && latestTerminalLogVersion) { - dispatchTabSelection({ type: 'activate', tabId: CONSOLE_TAB_ID }); + dispatchTabSelection({ type: 'activateAutomatically', tabId: CONSOLE_TAB_ID }); } }, [consoleMode, latestTerminalLogVersion]); useEffect(() => { if (consoleMode && props.forceOutputTab) { - dispatchTabSelection({ type: 'activate', tabId: CONSOLE_TAB_ID }); + dispatchTabSelection({ type: 'activateAutomatically', tabId: CONSOLE_TAB_ID }); } }, [consoleMode, props.forceOutputTab, props.resultBatchKey]); - const onChange = useCallback((uuid) => { - dispatchTabSelection({ type: 'activate', tabId: uuid }); - }, []); + const onChange = useCallback( + (uuid) => { + dispatchTabSelection({ type: consoleMode ? 'activateByUser' : 'activate', tabId: uuid }); + }, + [consoleMode], + ); const tabsList = useMemo(() => { - const visibleResultDataList = showHistory - ? [...(resultDataList || []), ...visibleHistoryResultDataList] - : resultDataList || []; + const visibleResultDataList = consoleMode + ? orderedConsoleResultDataList + : showHistory + ? [...(resultDataList || []), ...visibleHistoryResultDataList] + : resultDataList || []; if (!visibleResultDataList?.length) return []; const newResultDataList = visibleResultDataList?.filter(consoleMode ? hasTabularResult : hasLegacyResultTab); const tabsListRes = newResultDataList?.map((queryResultData, index) => { return { - prefixIcon: ( - - ), + prefixIcon: , popover: ( ), - label: - queryResultData.displayName || queryResultData.comment || i18n('common.text.executionResult', index + 1), + label: consoleMode + ? getConsoleResultTabLabel( + queryResultData.displayName || + queryResultData.comment || + i18n('common.text.executionResult', index + 1), + props.showExecutionResultCoordinates ?? true, + ) + : queryResultData.displayName || + queryResultData.comment || + i18n('common.text.executionResult', index + 1), key: queryResultData.uuid!, children: ( { + setResultTabOrder(nextOrder); + persistResultTabOrder(getResultTabPreferenceStorage(), RESULT_TAB_ORDER_STORAGE_KEY, nextOrder); + }, []); + + const handleResultSettingsClick = useCallback>( + ({ key }) => { + if (key === 'toggle-result-tab-order') { + handleResultTabOrderChange( + resultTabOrder === 'oldest-first' ? 'newest-first' : 'oldest-first', + ); + } else if (key === 'keep-result-history') { + props.onKeepResultHistoryChange?.(!(props.keepResultHistory ?? true)); + } + }, + [handleResultTabOrderChange, props.keepResultHistory, props.onKeepResultHistoryChange, resultTabOrder], + ); const executionMessages = useMemo(() => { if (consoleMode || !resultDataList?.length) { @@ -279,10 +368,10 @@ const SearchResult = forwardRef((props: IProps, ref: ForwardedRef - [...(resultDataList || []), ...historyResultDataList].some( + consoleResultDataList.some( (item) => hasTabularResult(item) && item.extra?.resultKey === resultKey, ), - [resultDataList, historyResultDataList], + [consoleResultDataList], ); const handleOpenResult = useCallback( @@ -291,18 +380,20 @@ const SearchResult = forwardRef((props: IProps, ref: ForwardedRef hasTabularResult(item) && item.extra?.resultKey === resultKey, ); if (currentResult?.uuid) { - dispatchTabSelection({ type: 'activate', tabId: currentResult.uuid }); + dispatchTabSelection({ type: consoleMode ? 'activateByUser' : 'activate', tabId: currentResult.uuid }); return; } const historyResult = historyResultDataList.find( (item) => hasTabularResult(item) && item.extra?.resultKey === resultKey, ); if (historyResult?.uuid) { - setShowHistory(true); - dispatchTabSelection({ type: 'activate', tabId: historyResult.uuid }); + if (!consoleMode) { + setShowHistory(true); + } + dispatchTabSelection({ type: consoleMode ? 'activateByUser' : 'activate', tabId: historyResult.uuid }); } }, - [resultDataList, historyResultDataList], + [resultDataList, historyResultDataList, consoleMode], ); const consoleTab = useMemo(() => { @@ -314,6 +405,11 @@ const SearchResult = forwardRef((props: IProps, ref: ForwardedRef { + (type: 'add' | 'remove', data: ITabItem[]) => { if (type === 'remove') { - const isCloseAll = list === undefined; - if (isCloseAll) { - const nextResultDataList: IManageResultData[] = []; - const nextHistoryResultDataList: IManageResultData[] = []; - setResultDataList(nextResultDataList); - setHistoryResultDataList(nextHistoryResultDataList); - props.onResultDataListChange?.({ - resultDataList: nextResultDataList, - historyResultDataList: nextHistoryResultDataList, - }); - return; - } const closedKeys = new Set((data || []).map((item) => item.key)); + const closedResultIdentities = [...(resultDataList || []), ...historyResultDataList] + .filter((result) => closedKeys.has(result.uuid || '')) + .map(getSqlExecutionResultIdentity) + .filter((identity): identity is SqlExecutionResultIdentity => identity !== undefined); const newResultDataList = resultDataList?.filter((d) => { return data.findIndex((item) => item.key === d.uuid) === -1; }); @@ -365,6 +453,7 @@ const SearchResult = forwardRef((props: IProps, ref: ForwardedRef - {!!visibleHistoryResultDataList.length && !viewTable && ( + {!consoleMode && !!visibleHistoryResultDataList.length && !viewTable && (
+ + ) : undefined + } + activateOnContextMenu={consoleMode} + activeTabScrollKey={consoleMode ? resultTabOrder : undefined} + closeActiveTabOnCloseShortcut={consoleMode && props.closeActiveResultShortcutEnabled} + closeShortcutAction={consoleMode ? ShortcutAction.CloseCurrentConsole : undefined} /> ) : (
diff --git a/chat2db-community-client/src/blocks/SearchResult/resultTabPreferences.test.ts b/chat2db-community-client/src/blocks/SearchResult/resultTabPreferences.test.ts new file mode 100644 index 0000000000..9efe5406fb --- /dev/null +++ b/chat2db-community-client/src/blocks/SearchResult/resultTabPreferences.test.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import type { IManageResultData } from '@/typings'; +import { + createResultTabKeepHistoryStorageKey, + createResultTabOrderStorageKey, + orderExecutionResultsByBatch, + persistResultTabKeepHistory, + persistResultTabOrder, + readResultTabKeepHistory, + readResultTabOrder, + subscribeResultTabKeepHistory, + subscribeResultTabOrder, +} from './resultTabPreferences'; + +function result(id: string, executionSequence?: number): IManageResultData { + return { + uuid: id, + dataList: [], + headerList: [], + description: '', + sql: `select '${id}'`, + originalSql: `select '${id}'`, + success: true, + duration: 0, + sqlType: 'SELECT' as IManageResultData['sqlType'], + refreshTargets: [], + extra: executionSequence === undefined ? {} : { executionSequence }, + pageNo: 1, + pageSize: 20, + fuzzyTotal: '0', + hasNextPage: false, + }; +} + +const keepHistoryStorageKey = createResultTabKeepHistoryStorageKey('community', 'community'); +const orderStorageKey = createResultTabOrderStorageKey('community', 'community'); +const values = new Map(); +const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, +}; + +assert.equal(keepHistoryStorageKey, 'chat2db.community.community.result-tabs.keep-history.v1'); +assert.equal(orderStorageKey, 'chat2db.community.community.result-tabs.order.v1'); +assert.notEqual( + keepHistoryStorageKey, + createResultTabKeepHistoryStorageKey('community', 'desktop'), + 'runtime modes keep independent result history preferences', +); +assert.notEqual( + orderStorageKey, + createResultTabOrderStorageKey('enterprise', 'community'), + 'client editions keep independent result order preferences', +); + +assert.equal(readResultTabKeepHistory(undefined, keepHistoryStorageKey), true); +assert.equal(readResultTabKeepHistory(storage, keepHistoryStorageKey), true); +values.set(keepHistoryStorageKey, 'invalid'); +assert.equal(readResultTabKeepHistory(storage, keepHistoryStorageKey), true); +values.set(keepHistoryStorageKey, 'false'); +assert.equal(readResultTabKeepHistory(storage, keepHistoryStorageKey), false); +values.set(keepHistoryStorageKey, 'true'); +assert.equal(readResultTabKeepHistory(storage, keepHistoryStorageKey), true); +persistResultTabKeepHistory(storage, keepHistoryStorageKey, false); +assert.equal(values.get(keepHistoryStorageKey), 'false'); + +assert.equal(readResultTabOrder(undefined, orderStorageKey), 'oldest-first'); +assert.equal(readResultTabOrder(storage, orderStorageKey), 'oldest-first'); +values.set(orderStorageKey, 'invalid'); +assert.equal(readResultTabOrder(storage, orderStorageKey), 'oldest-first'); +values.set(orderStorageKey, 'newest-first'); +assert.equal(readResultTabOrder(storage, orderStorageKey), 'newest-first'); +persistResultTabOrder(storage, orderStorageKey, 'oldest-first'); +assert.equal(values.get(orderStorageKey), 'oldest-first'); + +const keepHistoryUpdates: Array<[string, boolean]> = []; +const secondKeepHistoryUpdates: Array<[string, boolean]> = []; +const unsubscribeKeepHistory = subscribeResultTabKeepHistory((key, keepHistory) => { + keepHistoryUpdates.push([key, keepHistory]); +}); +const unsubscribeSecondKeepHistory = subscribeResultTabKeepHistory((key, keepHistory) => { + secondKeepHistoryUpdates.push([key, keepHistory]); +}); +persistResultTabKeepHistory(storage, keepHistoryStorageKey, true); +assert.deepEqual(keepHistoryUpdates, [[keepHistoryStorageKey, true]]); +assert.deepEqual(secondKeepHistoryUpdates, [[keepHistoryStorageKey, true]]); +unsubscribeKeepHistory(); +persistResultTabKeepHistory(storage, keepHistoryStorageKey, false); +assert.deepEqual(keepHistoryUpdates, [[keepHistoryStorageKey, true]], 'unsubscribed editors stop receiving history'); +assert.deepEqual(secondKeepHistoryUpdates, [ + [keepHistoryStorageKey, true], + [keepHistoryStorageKey, false], +]); +unsubscribeSecondKeepHistory(); + +const orderUpdates: Array<[string, string]> = []; +const secondOrderUpdates: Array<[string, string]> = []; +const unsubscribeOrder = subscribeResultTabOrder((key, order) => { + orderUpdates.push([key, order]); +}); +const unsubscribeSecondOrder = subscribeResultTabOrder((key, order) => { + secondOrderUpdates.push([key, order]); +}); +persistResultTabOrder(storage, orderStorageKey, 'newest-first'); +assert.deepEqual(orderUpdates, [[orderStorageKey, 'newest-first']]); +assert.deepEqual(secondOrderUpdates, [[orderStorageKey, 'newest-first']]); +unsubscribeOrder(); +persistResultTabOrder(storage, orderStorageKey, 'oldest-first'); +assert.deepEqual(orderUpdates, [[orderStorageKey, 'newest-first']], 'unsubscribed editors stop receiving order'); +assert.deepEqual(secondOrderUpdates, [ + [orderStorageKey, 'newest-first'], + [orderStorageKey, 'oldest-first'], +]); +unsubscribeSecondOrder(); + +const unavailableStorage = { + getItem: () => { + throw new Error('unavailable'); + }, + setItem: () => { + throw new Error('unavailable'); + }, +}; +assert.equal(readResultTabKeepHistory(unavailableStorage, keepHistoryStorageKey), true); +assert.doesNotThrow(() => persistResultTabKeepHistory(unavailableStorage, keepHistoryStorageKey, false)); +assert.equal(readResultTabOrder(unavailableStorage, orderStorageKey), 'oldest-first'); +assert.doesNotThrow(() => persistResultTabOrder(unavailableStorage, orderStorageKey, 'newest-first')); + +const results = [ + result('execution-1-statement-1', 1), + result('execution-1-statement-2-result-1', 1), + result('execution-1-statement-2-result-2', 1), + result('execution-2-statement-1', 2), + result('execution-3-statement-1', 3), + result('execution-3-statement-2', 3), +]; + +assert.deepEqual( + orderExecutionResultsByBatch(results, 'oldest-first').map((item) => item.uuid), + results.map((item) => item.uuid), +); +assert.deepEqual( + orderExecutionResultsByBatch(results, 'newest-first').map((item) => item.uuid), + [ + 'execution-3-statement-1', + 'execution-3-statement-2', + 'execution-2-statement-1', + 'execution-1-statement-1', + 'execution-1-statement-2-result-1', + 'execution-1-statement-2-result-2', + ], + 'newest-first reverses execution batches without reversing results inside a batch', +); +assert.deepEqual( + results.map((item) => item.uuid), + [ + 'execution-1-statement-1', + 'execution-1-statement-2-result-1', + 'execution-1-statement-2-result-2', + 'execution-2-statement-1', + 'execution-3-statement-1', + 'execution-3-statement-2', + ], + 'ordering does not mutate canonical result state', +); + +const resultsWithLegacyEntries = [ + result('legacy-first'), + result('execution-1', 1), + result('legacy-second'), + result('execution-2', 2), +]; +assert.deepEqual( + orderExecutionResultsByBatch(resultsWithLegacyEntries, 'newest-first').map((item) => item.uuid), + ['execution-2', 'execution-1', 'legacy-first', 'legacy-second'], + 'legacy results remain stable and are not discarded when their batch cannot be determined', +); + +console.log('Result tab preference tests passed'); diff --git a/chat2db-community-client/src/blocks/SearchResult/resultTabPreferences.ts b/chat2db-community-client/src/blocks/SearchResult/resultTabPreferences.ts new file mode 100644 index 0000000000..b2ee7ddb8b --- /dev/null +++ b/chat2db-community-client/src/blocks/SearchResult/resultTabPreferences.ts @@ -0,0 +1,137 @@ +import type { IManageResultData } from '@/typings'; + +export type ResultTabOrder = 'oldest-first' | 'newest-first'; + +interface ResultTabPreferenceStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +type ResultTabKeepHistoryListener = (storageKey: string, keepHistory: boolean) => void; +type ResultTabOrderListener = (storageKey: string, order: ResultTabOrder) => void; + +export const DEFAULT_RESULT_TAB_KEEP_HISTORY = true; +export const DEFAULT_RESULT_TAB_ORDER: ResultTabOrder = 'oldest-first'; + +const keepHistoryListeners = new Set(); +const orderListeners = new Set(); + +export function createResultTabKeepHistoryStorageKey(clientEdition: string, runtimeEnv: string) { + return `chat2db.${clientEdition}.${runtimeEnv}.result-tabs.keep-history.v1`; +} + +export function createResultTabOrderStorageKey(clientEdition: string, runtimeEnv: string) { + return `chat2db.${clientEdition}.${runtimeEnv}.result-tabs.order.v1`; +} + +export function getResultTabPreferenceStorage(): ResultTabPreferenceStorage | undefined { + try { + return typeof window === 'undefined' ? undefined : window.localStorage; + } catch { + return undefined; + } +} + +export function readResultTabKeepHistory( + storage: ResultTabPreferenceStorage | undefined, + storageKey: string, +) { + try { + const storedValue = storage?.getItem(storageKey); + if (storedValue === 'true') return true; + if (storedValue === 'false') return false; + return DEFAULT_RESULT_TAB_KEEP_HISTORY; + } catch { + return DEFAULT_RESULT_TAB_KEEP_HISTORY; + } +} + +export function persistResultTabKeepHistory( + storage: ResultTabPreferenceStorage | undefined, + storageKey: string, + keepHistory: boolean, +) { + try { + storage?.setItem(storageKey, String(keepHistory)); + } catch { + // Storage can be unavailable in restricted browser contexts. + } + keepHistoryListeners.forEach((listener) => listener(storageKey, keepHistory)); +} + +export function subscribeResultTabKeepHistory(listener: ResultTabKeepHistoryListener) { + keepHistoryListeners.add(listener); + return () => { + keepHistoryListeners.delete(listener); + }; +} + +export function readResultTabOrder( + storage: ResultTabPreferenceStorage | undefined, + storageKey: string, +): ResultTabOrder { + try { + const storedValue = storage?.getItem(storageKey); + return storedValue === 'oldest-first' || storedValue === 'newest-first' + ? storedValue + : DEFAULT_RESULT_TAB_ORDER; + } catch { + return DEFAULT_RESULT_TAB_ORDER; + } +} + +export function persistResultTabOrder( + storage: ResultTabPreferenceStorage | undefined, + storageKey: string, + order: ResultTabOrder, +) { + try { + storage?.setItem(storageKey, order); + } catch { + // Storage can be unavailable in restricted browser contexts. + } + orderListeners.forEach((listener) => listener(storageKey, order)); +} + +export function subscribeResultTabOrder(listener: ResultTabOrderListener) { + orderListeners.add(listener); + return () => { + orderListeners.delete(listener); + }; +} + +export function orderExecutionResultsByBatch( + results: readonly IManageResultData[], + order: ResultTabOrder, +): IManageResultData[] { + if (order === 'oldest-first') { + return [...results]; + } + + const executionGroups = new Map(); + const legacyResults: IManageResultData[] = []; + results.forEach((result) => { + const executionSequence = positiveInteger(result.extra?.executionSequence); + if (executionSequence === undefined) { + legacyResults.push(result); + return; + } + + const group = executionGroups.get(executionSequence); + if (group) { + group.push(result); + } else { + executionGroups.set(executionSequence, [result]); + } + }); + + const orderedExecutionResults = Array.from(executionGroups.values()) + .reverse() + .flat(); + return [...orderedExecutionResults, ...legacyResults]; +} + +function positiveInteger(value: unknown) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : undefined; +} diff --git a/chat2db-community-client/src/blocks/SearchResult/style.ts b/chat2db-community-client/src/blocks/SearchResult/style.ts index 02fbf27012..988267c09a 100644 --- a/chat2db-community-client/src/blocks/SearchResult/style.ts +++ b/chat2db-community-client/src/blocks/SearchResult/style.ts @@ -38,15 +38,28 @@ export const useStyles = createStyles(({ css, token }) => { font-size: 16px; margin-right: 4px; `, - statusIcon: css` - margin-right: 6px; - font-size: 12px; - `, - successIcon: css` - color: ${token.colorPrimary}; + resultTabIcon: css` + margin-right: 4px; + color: inherit; `, - failIcon: css` - color: ${token.colorError}; + iconButtonTrigger: css` + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin: 0; + padding: 0; + border: 0; + border-radius: 4px; + outline: none; + color: inherit; + background: transparent; + cursor: pointer; + + &:focus-visible { + box-shadow: inset 0 0 0 2px ${token.colorPrimary}; + } `, tableIndex: css` width: 50px; diff --git a/chat2db-community-client/src/blocks/SearchResult/tabSelection.test.ts b/chat2db-community-client/src/blocks/SearchResult/tabSelection.test.ts index 880f0f10da..ab84e26b84 100644 --- a/chat2db-community-client/src/blocks/SearchResult/tabSelection.test.ts +++ b/chat2db-community-client/src/blocks/SearchResult/tabSelection.test.ts @@ -2,6 +2,7 @@ import { ABSTRACT_TAB_ID, CONSOLE_TAB_ID, MESSAGES_TAB_ID, + getConsoleResultTabLabel, getPreferredActiveTabId, reduceActiveTabSelection, resolveAvailableActiveTabId, @@ -73,8 +74,28 @@ assertEqual( CONSOLE_TAB_ID, 'console failures open the execution console', ); +assertEqual( + getConsoleResultTabLabel('#3-1 select', false), + 'select', + 'a replacement batch hides both the batch and statement coordinate', +); +assertEqual( + getConsoleResultTabLabel('#3-2 select', false), + 'select', + 'a replacement batch does not retain a statement sequence in later result labels', +); +assertEqual( + getConsoleResultTabLabel('#3-1 Result 2 select', false), + 'Result 2 select', + 'a replacement batch preserves a multiple-result suffix after removing the execution coordinate', +); +assertEqual( + getConsoleResultTabLabel('#3-1 select', true), + '#3-1 select', + 'a retained batch keeps the complete batch and statement coordinate', +); let batchedSelection = reduceActiveTabSelection( - { activeTabId: CONSOLE_TAB_ID }, + { activeTabId: CONSOLE_TAB_ID, followPreferredTabs: true }, { type: 'prefer', tabId: 'batched-table-result' }, ); batchedSelection = reduceActiveTabSelection(batchedSelection, { @@ -119,6 +140,53 @@ assertEqual( undefined, 'failed and cancelled terminal states discard a pending result preference', ); + +let manualSelection = reduceActiveTabSelection( + { activeTabId: 'first-result', followPreferredTabs: true }, + { type: 'activateByUser', tabId: CONSOLE_TAB_ID }, +); +manualSelection = reduceActiveTabSelection(manualSelection, { type: 'prefer', tabId: 'later-result' }); +manualSelection = reduceActiveTabSelection(manualSelection, { + type: 'activateAutomatically', + tabId: 'later-result', +}); +manualSelection = reduceActiveTabSelection(manualSelection, { + type: 'tabsChanged', + availableTabIds: [CONSOLE_TAB_ID, 'first-result', 'later-result'], +}); +assertEqual( + manualSelection.activeTabId, + CONSOLE_TAB_ID, + 'later streamed results do not steal focus after the user selects Output', +); +assertEqual( + manualSelection.pendingPreferredTabId, + undefined, + 'manual selection prevents future automatic preferences from becoming pending', +); + +manualSelection = reduceActiveTabSelection(manualSelection, { type: 'resetPreference' }); +manualSelection = reduceActiveTabSelection(manualSelection, { type: 'prefer', tabId: 'result-after-clear' }); +assertEqual( + manualSelection.activeTabId, + CONSOLE_TAB_ID, + 'clearing Output does not restore automatic focus within the current batch', +); + +manualSelection = reduceActiveTabSelection(manualSelection, { + type: 'startAutoFollow', + tabId: CONSOLE_TAB_ID, +}); +manualSelection = reduceActiveTabSelection(manualSelection, { type: 'prefer', tabId: 'next-batch-result' }); +manualSelection = reduceActiveTabSelection(manualSelection, { + type: 'tabsChanged', + availableTabIds: [CONSOLE_TAB_ID, 'next-batch-result'], +}); +assertEqual( + manualSelection.activeTabId, + 'next-batch-result', + 'starting a new execution batch restores automatic result focus', +); assertEqual( resolveAvailableActiveTabId('table-result', [ABSTRACT_TAB_ID, 'table-result']), 'table-result', diff --git a/chat2db-community-client/src/blocks/SearchResult/tabSelection.ts b/chat2db-community-client/src/blocks/SearchResult/tabSelection.ts index 1e569dfc73..2940d80224 100644 --- a/chat2db-community-client/src/blocks/SearchResult/tabSelection.ts +++ b/chat2db-community-client/src/blocks/SearchResult/tabSelection.ts @@ -7,11 +7,15 @@ export const MESSAGES_TAB_ID = 'messages'; export interface ActiveTabSelectionState { activeTabId: string; pendingPreferredTabId?: string; + followPreferredTabs: boolean; } export type ActiveTabSelectionAction = | { type: 'prefer'; tabId: string } | { type: 'activate'; tabId: string } + | { type: 'activateAutomatically'; tabId: string } + | { type: 'activateByUser'; tabId: string } + | { type: 'startAutoFollow'; tabId: string } | { type: 'resetPreference' } | { type: 'tabsChanged'; availableTabIds: string[] }; @@ -27,6 +31,13 @@ export function hasLegacyResultTab(item: IManageResultData) { return hasTabularResult(item) || !item.success; } +export function getConsoleResultTabLabel(displayName: string, showCoordinates: boolean) { + if (showCoordinates) { + return displayName; + } + return displayName.replace(/^#\d+-\d+\s*/, ''); +} + export function shouldOpenLegacyMessagesTab(item: IManageResultData) { return item.extra?.messageOnly || (!!item.extra?.messages?.length && !hasLegacyResultTab(item)); } @@ -77,11 +88,22 @@ export function reduceActiveTabSelection( ): ActiveTabSelectionState { switch (action.type) { case 'prefer': + if (!state.followPreferredTabs) { + return state; + } return { ...state, pendingPreferredTabId: action.tabId }; case 'activate': - return { activeTabId: action.tabId }; + return { ...state, activeTabId: action.tabId, pendingPreferredTabId: undefined }; + case 'activateAutomatically': + return state.followPreferredTabs + ? { ...state, activeTabId: action.tabId, pendingPreferredTabId: undefined } + : state; + case 'activateByUser': + return { activeTabId: action.tabId, followPreferredTabs: false }; + case 'startAutoFollow': + return { activeTabId: action.tabId, followPreferredTabs: true }; case 'resetPreference': - return state.pendingPreferredTabId === undefined ? state : { activeTabId: state.activeTabId }; + return state.pendingPreferredTabId === undefined ? state : { ...state, pendingPreferredTabId: undefined }; case 'tabsChanged': { const activeTabId = resolveAvailableActiveTabId( state.activeTabId, @@ -93,7 +115,7 @@ export function reduceActiveTabSelection( if (activeTabId === state.activeTabId && pendingPreferredTabId === state.pendingPreferredTabId) { return state; } - return { activeTabId, pendingPreferredTabId }; + return { ...state, activeTabId, pendingPreferredTabId }; } default: return state; diff --git a/chat2db-community-client/src/blocks/Setting/CommunitySetting.tsx b/chat2db-community-client/src/blocks/Setting/CommunitySetting.tsx index 4d9a758f77..118d3abe1a 100644 --- a/chat2db-community-client/src/blocks/Setting/CommunitySetting.tsx +++ b/chat2db-community-client/src/blocks/Setting/CommunitySetting.tsx @@ -1,5 +1,5 @@ import i18n from '@/i18n'; -import { ClipboardPen, Info, Keyboard, SlidersHorizontal } from 'lucide-react'; +import { ClipboardPen, Info, Keyboard, SlidersHorizontal, Terminal } from 'lucide-react'; import { useEffect, useMemo } from 'react'; import About from './About'; import BaseSetting from './BaseSetting'; @@ -8,9 +8,11 @@ import McpSetting from './McpSetting'; import NetworkProxySetting from './NetworkProxySetting'; import SettingLayout, { type SettingMenuItem } from './SettingLayout'; import ShortcutSetting from './ShortcutSetting'; +import TerminalSetting from './TerminalSetting'; import { runtimeEditionConfig } from '@/constants/runtimeEdition'; import { useGlobalStore } from '@/store/global'; +import { isDesktop } from '@/utils/env'; function CommunitySetting() { const { @@ -42,6 +44,18 @@ function CommunitySetting() { body: , code: 'editSetting', }, + ...(isDesktop + ? [ + { + title: i18n('setting.nav.terminal'), + describe: i18n('setting.nav.terminalDescribe'), + group: 'general' as const, + icon: Terminal, + body: , + code: 'terminal', + }, + ] + : []), ...(runtimeEditionConfig.mcpSetting ? [ { diff --git a/chat2db-community-client/src/blocks/Setting/ShortcutSetting/ShortcutInput/index.tsx b/chat2db-community-client/src/blocks/Setting/ShortcutSetting/ShortcutInput/index.tsx index 5637d88b9a..11e984b974 100644 --- a/chat2db-community-client/src/blocks/Setting/ShortcutSetting/ShortcutInput/index.tsx +++ b/chat2db-community-client/src/blocks/Setting/ShortcutSetting/ShortcutInput/index.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { useStyles } from './style'; -import { normalizeShortcutBinding } from '@/constants/shortcut'; +import { getEventShortcutBinding, isShortcutCaptureAllowed, normalizeShortcutBinding } from '@/constants/shortcut'; import { i18n } from '@/i18n'; interface IProps { @@ -24,27 +24,6 @@ const modifierOrder = { shift: 3, }; -const modifierKeys = new Set(['meta', 'control', 'alt', 'shift']); -const singleKeyShortcuts = new Set([ - 'enter', - 'escape', - 'tab', - 'backspace', - 'delete', - 'f1', - 'f2', - 'f3', - 'f4', - 'f5', - 'f6', - 'f7', - 'f8', - 'f9', - 'f10', - 'f11', - 'f12', -]); - const getShortcutKeys = (shortcut?: string | null) => { const normalizedShortcut = normalizeShortcutBinding(shortcut); if (!normalizedShortcut) { @@ -65,14 +44,20 @@ const ShortcutInput: React.FC = ({ value, onChange, disabled, placeholde pendingShortcutRef.current = value || ''; }, [value]); - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (disabled) return; e.preventDefault(); const key = e.key.toLowerCase(); - keysPressed.current.add(key); + const binding = getEventShortcutBinding(e); + if (binding) { + pendingShortcutRef.current = binding; + setDisplayValue(binding); + return; + } + const shortcut = Array.from(keysPressed.current) .sort((a, b) => { const orderA = modifierOrder[a as keyof typeof modifierOrder] || 999; @@ -91,23 +76,18 @@ const ShortcutInput: React.FC = ({ value, onChange, disabled, placeholde if (disabled) return; if (!keysPressed.current.size) return; - // Restore the original value for incomplete shortcuts without a supported single key or modifier. - const pressedKeys = Array.from(keysPressed.current); - const hasModifierKey = pressedKeys.some((key) => modifierKeys.has(key)); - const isAllowedSingleKey = pressedKeys.length === 1 && singleKeyShortcuts.has(pressedKeys[0]); - - if (!isAllowedSingleKey && (keysPressed.current.size <= 1 || !hasModifierKey)) { + if (!isShortcutCaptureAllowed(keysPressed.current)) { keysPressed.current.clear(); setDisplayValue(normalizeShortcutBinding(value) || ''); + pendingShortcutRef.current = value || ''; return; } - keysPressed.current.clear(); - // only updates shortcut keys when out of focus const normalizedValue = normalizeShortcutBinding(pendingShortcutRef.current); if (normalizedValue && normalizedValue !== value) { onChange(normalizedValue); } + keysPressed.current.clear(); }; const shortcutKeys = getShortcutKeys(displayValue); diff --git a/chat2db-community-client/src/blocks/Setting/ShortcutSetting/index.tsx b/chat2db-community-client/src/blocks/Setting/ShortcutSetting/index.tsx index 03ea02c7f7..8967577398 100644 --- a/chat2db-community-client/src/blocks/Setting/ShortcutSetting/index.tsx +++ b/chat2db-community-client/src/blocks/Setting/ShortcutSetting/index.tsx @@ -36,6 +36,11 @@ const SHORTCUT_SCOPE_GROUPS: Array<{ targetId: 'shortcut.workspace', title: 'setting.shortcut.group.workspace', }, + { + scope: ShortcutScope.DatabaseTree, + targetId: 'shortcut.databaseTree', + title: 'setting.shortcut.group.databaseTree', + }, { scope: ShortcutScope.LocalSqlFileTree, targetId: 'shortcut.localSqlFileTree', diff --git a/chat2db-community-client/src/blocks/Setting/TerminalSetting/index.tsx b/chat2db-community-client/src/blocks/Setting/TerminalSetting/index.tsx new file mode 100644 index 0000000000..47e30b8d6e --- /dev/null +++ b/chat2db-community-client/src/blocks/Setting/TerminalSetting/index.tsx @@ -0,0 +1,243 @@ +import { DEFAULT_TERMINAL_SETTINGS, TERMINAL_THEMES } from '@/constants/terminal'; +import i18n from '@/i18n'; +import jcefApi from '@/jcef'; +import { useGlobalStore } from '@/store/global'; +import type { TerminalOpenPosition, TerminalShellId, TerminalThemeId } from '@/typings/settings'; +import { Alert, Segmented, Select, Spin, Switch } from 'antd'; +import { Check, ChevronDown, Palette, PanelBottom, PanelRight, PanelTop, ShieldCheck, Terminal } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useStyles } from './style'; + +interface ShellOption { + id: TerminalShellId; + label: string; + available: boolean; +} + +const themeOptions = Object.values(TERMINAL_THEMES); + +export default function TerminalSetting() { + const { styles, cx } = useStyles(); + const { terminalSettings, updateTerminalSettings } = useGlobalStore((state) => ({ + terminalSettings: { + ...DEFAULT_TERMINAL_SETTINGS, + ...state.terminalSettings, + }, + updateTerminalSettings: state.updateTerminalSettings, + })); + const [shells, setShells] = useState([]); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); + + useEffect(() => { + let disposed = false; + jcefApi + .getTerminalCapabilities() + .then((capabilities) => { + if (disposed) { + return; + } + const availableShells = capabilities.shells.filter((shell) => shell.available) as ShellOption[]; + setShells(availableShells); + if (!availableShells.some((shell) => shell.id === terminalSettings.shellId)) { + updateTerminalSettings({ shellId: 'system' }); + } + }) + .catch((error) => { + console.error('get terminal capabilities error', error); + if (!disposed) { + setLoadFailed(true); + } + }) + .finally(() => { + if (!disposed) { + setLoading(false); + } + }); + return () => { + disposed = true; + }; + }, [terminalSettings.shellId, updateTerminalSettings]); + + return ( +
+
+
+
+
+ +
+
+ +
+
+
+
+ updateTerminalSettings({ confirmBeforeClose })} + /> +
+
+ +
+
+
+
+ {loading ? ( +
+ +
+ ) : loadFailed ? ( + + ) : ( + event.currentTarget.select()} + onChange={(event) => onNameChange(event.target.value)} + onPressEnter={() => { + if (valid && !loading) { + onConfirm(); + } + }} + /> + + + + ); +}; + +export default InitialSaveNameModal; diff --git a/chat2db-community-client/src/components/SQLEditor/editor/SQLEditorWithOperation/index.tsx b/chat2db-community-client/src/components/SQLEditor/editor/SQLEditorWithOperation/index.tsx index 70d1818d59..6e8ee0b8cc 100644 --- a/chat2db-community-client/src/components/SQLEditor/editor/SQLEditorWithOperation/index.tsx +++ b/chat2db-community-client/src/components/SQLEditor/editor/SQLEditorWithOperation/index.tsx @@ -3,6 +3,7 @@ import { useStyles } from './style'; import { OperationLine, MonacoEditorErrorTips } from '../../components'; import SQLEditor, { SQLEditorRef } from '../SQLEditor'; import RoutineOperationModals from './RoutineOperationModals'; +import InitialSaveNameModal from './InitialSaveNameModal'; import { EditorSetValueType, EditorType, SQLOptType } from '../../type'; import { staticMessage } from '@chat2db/ui'; import { IConsoleReturnExecuteSql, IBoundInfo, TreeNodeData } from '@/typings'; @@ -41,6 +42,7 @@ import { getContentDiffEligibility, getContentDiffOpenBlockReason, } from '../../helper/contentDiffGuard'; +import { normalizeSavedConsoleName, resolveInitialSavedConsoleName } from '../../helper/savedConsoleName'; interface ISQLEditorWithOperationProps { id: string; @@ -62,6 +64,7 @@ interface ISQLEditorWithOperationProps { reloadSQL?: () => Promise; onExecuteSQL: (props: IConsoleReturnExecuteSql) => Promise; + onChange?: (value: string) => void; } export interface ISQLEditorWithOperationRef extends SQLEditorRef { @@ -113,6 +116,7 @@ const SQLEditorWithOperation = forwardRef(null); const [errorMessage, setErrorMessage] = useState(null); const [hasEditorContent, setHasEditorContent] = useState(!!defaultSQL?.trim()); + const [initialSaveNameModalOpen, setInitialSaveNameModalOpen] = useState(false); + const [initialSaveName, setInitialSaveName] = useState(''); + const [initialSaveLoading, setInitialSaveLoading] = useState(false); const handleEditorContentChange = useCallback((value: string) => { setHasEditorContent(!!value.trim()); }, []); @@ -232,6 +239,7 @@ const SQLEditorWithOperation = forwardRef { + const name = normalizeSavedConsoleName(initialSaveName); + if (!name || initialSaveLoading) { + return; + } + + setInitialSaveLoading(true); + try { + await saveConsole(getValue(), { mode: 'manual', initialName: name }); + setInitialSaveNameModalOpen(false); + } catch { + // The request layer presents the error. Keep the name dialog open for retry. + } finally { + setInitialSaveLoading(false); + } + }, [getValue, initialSaveLoading, initialSaveName, saveConsole]); + + const handleCancelInitialSave = useCallback(() => { + if (initialSaveLoading) { + return; + } + setInitialSaveNameModalOpen(false); + }, [initialSaveLoading]); const handleSaveFile = () => { const fileContent = sqlEditorRef.current?.getValue() ?? ''; @@ -945,6 +1007,7 @@ const SQLEditorWithOperation = forwardRef +
); }); diff --git a/chat2db-community-client/src/components/SQLEditor/helper/savedConsoleName.ts b/chat2db-community-client/src/components/SQLEditor/helper/savedConsoleName.ts new file mode 100644 index 0000000000..617f468b2e --- /dev/null +++ b/chat2db-community-client/src/components/SQLEditor/helper/savedConsoleName.ts @@ -0,0 +1,11 @@ +export const normalizeSavedConsoleName = (value?: string) => value?.trim() || ''; + +export const resolveInitialSavedConsoleName = (params: { + workspaceTitle?: string; + databaseName?: string; + schemaName?: string; + fallback: string; +}) => { + const { workspaceTitle, databaseName, schemaName, fallback } = params; + return [workspaceTitle, databaseName, schemaName, fallback].map(normalizeSavedConsoleName).find(Boolean) || ''; +}; diff --git a/chat2db-community-client/src/components/SQLEditor/hooks/useSaveEditorData.ts b/chat2db-community-client/src/components/SQLEditor/hooks/useSaveEditorData.ts index b6f66ffb99..c8395e2d37 100644 --- a/chat2db-community-client/src/components/SQLEditor/hooks/useSaveEditorData.ts +++ b/chat2db-community-client/src/components/SQLEditor/hooks/useSaveEditorData.ts @@ -7,18 +7,23 @@ import { staticMessage } from '@chat2db/ui'; import { useUserStore } from '@/store/user'; import { useIndexDBStore } from '@/store/indexDB'; import { SQLEditorRef } from '@/components/SQLEditor/editor/SQLEditor'; +import type { IBoundInfo } from '@/typings'; import { isTemporaryId } from '@/utils'; import { emitSavedConsoleUpdated } from '@/utils/savedConsoleEvents'; -import { savedConsoleMutationCoordinator } from '@/store/workspace/utils/savedConsoleMutationCoordinator'; +import { + savedConsoleMutationCoordinator, + type SavedConsoleSaveMode, +} from '@/store/workspace/utils/savedConsoleMutationCoordinator'; import { persistSavedConsoleRecord } from '@/store/workspace/utils/savedConsolePersistence'; interface IProps { isActive?: boolean; source?: 'workspace'; editorRef: React.RefObject; - boundInfo: any; + boundInfo: IBoundInfo; defaultValue?: string; name?: string; + onBoundInfoChange?: (boundInfo: IBoundInfo) => void; type: | WorkspaceTabType.CONSOLE | WorkspaceTabType.FUNCTION @@ -28,23 +33,26 @@ interface IProps { | WorkspaceTabType.LocalSQLFile; } +interface SaveConsoleOptions { + mode?: SavedConsoleSaveMode; + initialName?: string; +} + export const useSaveEditorData = (props: IProps) => { - const { isActive, source, editorRef, boundInfo, defaultValue, name, type } = props; + const { isActive, source, editorRef, boundInfo, defaultValue, name, onBoundInfoChange, type } = props; const timerRef = useRef(); + const effectiveConsoleIdRef = useRef(boundInfo?.consoleId); // Console data from the previous synchronization. const lastSyncConsole = useRef(defaultValue); const storageId = boundInfo?.workspaceTabId ?? boundInfo?.consoleId; - const isTemporaryConsole = isTemporaryId(storageId); - const isPersistedConsole = - type === WorkspaceTabType.CONSOLE && typeof boundInfo?.consoleId === 'number' && !isTemporaryConsole; const isReadOnly = !!boundInfo?.readOnly; const [saveStatus, setSaveStatus] = useState(boundInfo?.status || ConsoleStatus.DRAFT); const saveStatusRef = useRef(boundInfo?.status || ConsoleStatus.DRAFT); - const { getSavedConsoleList, savedConsoleList, updateWorkspaceTabBoundInfo } = useWorkspaceStore((s) => ({ + const { getSavedConsoleList, savedConsoleList, markWorkspaceTabConsoleSaved } = useWorkspaceStore((s) => ({ getSavedConsoleList: s.getSavedConsoleList, savedConsoleList: s.savedConsoleList, - updateWorkspaceTabBoundInfo: s.updateWorkspaceTabBoundInfo, + markWorkspaceTabConsoleSaved: s.markWorkspaceTabConsoleSaved, })); const hasSavedSqlRecord = Boolean( type === WorkspaceTabType.CONSOLE && @@ -65,24 +73,32 @@ export const useSaveEditorData = (props: IProps) => { }; }); - const saveConsole = (value?: string, noPrompting?: boolean) => { + const saveConsole = (value?: string, options: SaveConsoleOptions = {}) => { + const mode = options.mode || 'manual'; + const initialName = options.initialName?.trim(); + const consoleId = effectiveConsoleIdRef.current; const p: any = { - id: boundInfo.consoleId, + id: consoleId, status: ConsoleStatus.RELEASE, ddl: value, }; + if (initialName) { + p.name = initialName; + } if (!storageId) { - return; + return Promise.resolve(); } if (isReadOnly) { lastSyncConsole.current = value; - return; + return Promise.resolve(); } + const isPersistedConsole = + type === WorkspaceTabType.CONSOLE && typeof consoleId === 'number' && !isTemporaryId(consoleId); if (!isPersistedConsole) { - indexDB + return indexDB .setValue(storageId, { ddl: value, userId: curUser?.id, @@ -90,18 +106,15 @@ export const useSaveEditorData = (props: IProps) => { .then(() => { lastSyncConsole.current = value; }); - return; } - const consoleId = boundInfo.consoleId as number; - const mode = noPrompting ? 'automatic' : 'manual'; - savedConsoleMutationCoordinator + return savedConsoleMutationCoordinator .save(consoleId, mode, () => persistSavedConsoleRecord(historyServer, { - manual: !noPrompting, + manual: mode === 'manual', createParams: { id: consoleId, - name: name || boundInfo.databaseName || boundInfo.schemaName || '', + name: initialName || name || boundInfo.databaseName || boundInfo.schemaName || '', ddl: value || '', dataSourceId: boundInfo.dataSourceId, dataSourceName: boundInfo.dataSourceName, @@ -115,9 +128,9 @@ export const useSaveEditorData = (props: IProps) => { updateParams: p, }), ) - .then((result) => { + .then(async (result) => { if (!result.executed) { - indexDB + await indexDB .setValue(storageId, { ddl: value, userId: curUser?.id, @@ -130,18 +143,31 @@ export const useSaveEditorData = (props: IProps) => { if (!result.current) { return; } + const persistedConsoleId = result.value?.consoleId; + if (persistedConsoleId === undefined) { + return; + } + effectiveConsoleIdRef.current = persistedConsoleId; + const savedBoundInfo = { + ...boundInfo, + consoleId: persistedConsoleId, + status: ConsoleStatus.RELEASE, + }; + if (persistedConsoleId !== consoleId || saveStatusRef.current !== ConsoleStatus.RELEASE) { + onBoundInfoChange?.(savedBoundInfo); + } getSavedConsoleList(); - emitSavedConsoleUpdated(boundInfo); - indexDB.deleteValue(storageId); + emitSavedConsoleUpdated(savedBoundInfo); + void indexDB.deleteValue(storageId); lastSyncConsole.current = value; saveStatusRef.current = ConsoleStatus.RELEASE; setSaveStatus(ConsoleStatus.RELEASE); - updateWorkspaceTabBoundInfo({ + markWorkspaceTabConsoleSaved({ workspaceTabId: boundInfo.workspaceTabId, - consoleId: boundInfo.consoleId, - status: ConsoleStatus.RELEASE, + consoleId: persistedConsoleId, + name: initialName, }); - if (noPrompting) { + if (mode === 'automatic') { return; } staticMessage.success(i18n('common.tips.saveSuccessfully')); @@ -159,7 +185,7 @@ export const useSaveEditorData = (props: IProps) => { return; } if (saveStatusRef.current === ConsoleStatus.RELEASE) { - saveConsole(curValue, true); + void saveConsole(curValue, { mode: 'automatic' }); } else { if (isReadOnly || !storageId) { lastSyncConsole.current = curValue; @@ -192,7 +218,7 @@ export const useSaveEditorData = (props: IProps) => { return; } if (saveStatusRef.current === ConsoleStatus.RELEASE) { - saveConsole(curValue, true); + void saveConsole(curValue, { mode: 'automatic' }); } else { if (isReadOnly || !storageId) { lastSyncConsole.current = curValue; @@ -218,6 +244,10 @@ export const useSaveEditorData = (props: IProps) => { }; }, [isActive]); + useEffect(() => { + effectiveConsoleIdRef.current = boundInfo?.consoleId; + }, [boundInfo?.consoleId]); + useEffect(() => { const nextStatus = boundInfo?.status || ConsoleStatus.DRAFT; saveStatusRef.current = nextStatus; diff --git a/chat2db-community-client/src/components/SQLEditor/type.ts b/chat2db-community-client/src/components/SQLEditor/type.ts index 0047d068b6..fb8af2a91a 100644 --- a/chat2db-community-client/src/components/SQLEditor/type.ts +++ b/chat2db-community-client/src/components/SQLEditor/type.ts @@ -60,6 +60,8 @@ export enum SQLOptType { REFRESH_ROUTINE_DDL = 'REFRESH_ROUTINE_DDL', REVERT_ROUTINE_DDL = 'REVERT_ROUTINE_DDL', FORMAT_SQL = 'FORMAT_SQL', + TOGGLE_LINE_COMMENT = 'TOGGLE_LINE_COMMENT', + TOGGLE_BLOCK_COMMENT = 'TOGGLE_BLOCK_COMMENT', EXPLAIN_SQL = 'EXPLAIN_SQL', VIEW_TABLE_DDL = 'VIEW_TABLE_DDL', EDIT_TABLE = 'EDIT_TABLE', diff --git a/chat2db-community-client/src/components/SelectBoundInfo/index.tsx b/chat2db-community-client/src/components/SelectBoundInfo/index.tsx index 0880fe810b..386d91d41d 100644 --- a/chat2db-community-client/src/components/SelectBoundInfo/index.tsx +++ b/chat2db-community-client/src/components/SelectBoundInfo/index.tsx @@ -258,8 +258,8 @@ const SelectBoundInfo = memo( return (
- {selectedList.map((item, index) => { - return ; + {selectedList.map((item) => { + return ; })}
); diff --git a/chat2db-community-client/src/components/SelectDataCollection/index.tsx b/chat2db-community-client/src/components/SelectDataCollection/index.tsx index 881b61e5bf..f2c0d84001 100644 --- a/chat2db-community-client/src/components/SelectDataCollection/index.tsx +++ b/chat2db-community-client/src/components/SelectDataCollection/index.tsx @@ -1,4 +1,5 @@ -import { memo, useEffect, useState } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; +import { beginLatestRequest, invalidateLatestRequest, isLatestRequest } from '@/utils/latestRequest'; import i18n from '@/i18n'; import { useStyles } from './style'; import { IconfontSvg, ToolbarBtn } from '@chat2db/ui'; @@ -40,8 +41,12 @@ export default memo((props) => { defaultDataCollectionList: state.defaultDataCollectionList, })); + const requestGenerationRef = useRef(0); + useEffect(() => { + const requestGeneration = beginLatestRequest(requestGenerationRef); aiDataCollectionService.getAiDataCollectionList({ pageNo: 1, pageSize: 1000, dataSourceId }).then((res) => { + if (!isLatestRequest(requestGenerationRef, requestGeneration)) return; if (res) { const _options = res.data.map((item) => ({ label: item.title, @@ -50,6 +55,9 @@ export default memo((props) => { setOptions(_options); } }); + return () => { + invalidateLatestRequest(requestGenerationRef); + }; }, [dataSourceId, open]); useEffect(() => { diff --git a/chat2db-community-client/src/components/Tabs/index.tsx b/chat2db-community-client/src/components/Tabs/index.tsx index cb679b5b91..a8d7eeaeb1 100644 --- a/chat2db-community-client/src/components/Tabs/index.tsx +++ b/chat2db-community-client/src/components/Tabs/index.tsx @@ -4,7 +4,12 @@ import Iconfont from '@/components/Iconfont'; import { IconButton, IconfontSvg } from '@chat2db/ui'; import { Popover, Dropdown } from 'antd'; import PortalContextMenu from '@/components/ContextMenu/PortalContextMenu'; -import type { ContextMenuAction, ContextMenuEntry, ContextMenuIntent } from '@/components/ContextMenu/core'; +import { + isContextMenuAction, + type ContextMenuAction, + type ContextMenuEntry, + type ContextMenuIntent, +} from '@/components/ContextMenu/core'; import i18n from '@/i18n'; import { isValid } from '@/utils/check'; import { useStyles } from './style'; @@ -22,6 +27,10 @@ import { useSensors, } from '@dnd-kit/core'; import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable'; +import { + CloseActiveResultTabHandlerResult, + registerCloseActiveResultTabHandler, +} from '@/service/resultTabShortcut'; export interface ITabItem { prefixIcon?: string | React.ReactNode; @@ -33,6 +42,7 @@ export interface ITabItem { canClosed?: boolean; styles?: React.CSSProperties; pinned?: boolean; + destroyOnHide?: boolean; } export interface ITabContextActions { @@ -76,6 +86,7 @@ interface IProps { height?: number; onChange: (key: string | number | null) => void; onEdit?: (action: 'add' | 'remove', data?: ITabItem[], list?: ITabItem[]) => void; + beforeRemove?: (tabs: ITabItem[]) => boolean | Promise; hideAdd?: boolean; editableNameOnBlur?: (option: ITabItem) => void; concealTabHeader?: boolean; @@ -83,9 +94,15 @@ interface IProps { lastTabCannotClosed?: boolean; destroyInactiveTabPane?: boolean; tabMaxWidth?: string; + uniformTabWidth?: boolean; contextActions?: ITabContextActions; contextActionAvailability?: (tab: ITabItem) => ITabContextActions; contextActionHandlers?: ITabContextActionHandlers; + tabBarExtraContent?: React.ReactNode; + activateOnContextMenu?: boolean; + activeTabScrollKey?: string | number; + closeActiveTabOnCloseShortcut?: boolean; + closeShortcutAction?: ShortcutAction; useExternalSortableContext?: boolean; draggingTabKey?: string; onDraggingTabKeyChange?: (key?: string) => void; @@ -191,6 +208,7 @@ export default memo((props) => { items, onChange, onEdit, + beforeRemove, activeKey, hideAdd, lastTabCannotClosed, @@ -199,9 +217,15 @@ export default memo((props) => { destroyInactiveTabPane = false, height = 40, tabMaxWidth = 'none', + uniformTabWidth = false, contextActions, contextActionAvailability, contextActionHandlers, + tabBarExtraContent, + activateOnContextMenu = false, + activeTabScrollKey, + closeActiveTabOnCloseShortcut = false, + closeShortcutAction, useExternalSortableContext = false, draggingTabKey: controlledDraggingTabKey, onDraggingTabKeyChange, @@ -209,9 +233,23 @@ export default memo((props) => { } = props; const [internalTabs, setInternalTabs] = useState([]); const [editingTab, setEditingTab] = useState(); + const tabBoxRef = useRef(null); const tabListBoxRef = useRef(null); + const closeActiveTabOnShortcutRef = useRef<() => CloseActiveResultTabHandlerResult>( + () => 'inactive', + ); + const lastTabScrollRequestRef = useRef<{ + activeKey: IProps['activeKey']; + scrollKey: IProps['activeTabScrollKey']; + }>(); const [showAddButton, setShowAddButton] = useState(!hideAdd); - const { styles, cx } = useStyles({ height, showAddButton, tabMaxWidth }); + const { styles, cx } = useStyles({ + height, + showAddButton, + showTabBarExtraContent: !!tabBarExtraContent, + tabMaxWidth, + uniformTabWidth, + }); const [moreTabsDropdownOpen, setMoreTabsDropdownOpen] = useState(undefined); const [searchValue, setSearchValue] = useState(''); const [searchInternalTabs, setSearchInternalTabs] = useState(undefined); @@ -260,22 +298,45 @@ export default memo((props) => { }; }, [internalTabs]); - useUpdateEffect(() => { - // Move the focused tab to the first position. - setTimeout(() => { - if (tabListBoxRef.current) { - const activeTab = tabListBoxRef.current?.querySelector(`.${cx(styles.tabItem, styles.activeTab)}`); - if (activeTab) { - activeTab.scrollIntoView({ - block: 'nearest', - }); - } + useEffect(() => { + const nextTabs = items || []; + const renderedOrderIsCurrent = + internalTabs.length === nextTabs.length && + internalTabs.every((tab, index) => tab.key === nextTabs[index]?.key); + if (!renderedOrderIsCurrent) { + return; + } + + const lastRequest = lastTabScrollRequestRef.current; + if ( + lastRequest && + Object.is(lastRequest.activeKey, activeKey) && + Object.is(lastRequest.scrollKey, activeTabScrollKey) + ) { + return; + } + + const animationFrame = window.requestAnimationFrame(() => { + const activeTab = tabListBoxRef.current?.querySelector(`.${styles.activeTab}`); + if (!activeTab) { + return; } - }, 0); + activeTab.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + lastTabScrollRequestRef.current = { + activeKey, + scrollKey: activeTabScrollKey, + }; + }); + if (internalTabs.length >= MAX_TABS) { setShowAddButton(false); } - }, [activeKey]); + + return () => window.cancelAnimationFrame(animationFrame); + }, [activeKey, activeTabScrollKey, internalTabs, items]); // useEffect(() => { // // from copilot @@ -286,10 +347,13 @@ export default memo((props) => { // } // }, [internalTabs]); - const deleteTab = (data: ITabItem) => { + const deleteTab = async (data: ITabItem) => { if (!showClosed(data)) { return; } + if (beforeRemove && !(await beforeRemove([data]))) { + return; + } const newInternalTabs = internalTabs?.filter((t) => t.key !== data.key); let activeKeyTemp = activeKey; // When closing the active tab, select the previous tab or the next one if no previous tab exists. @@ -308,23 +372,50 @@ export default memo((props) => { onEdit?.('remove', [data], newInternalTabs); }; - const deleteOtherTab = (data: ITabItem) => { - const newInternalTabs = internalTabs?.filter((t) => t.key === data.key || t.pinned); - const deleteTabs = internalTabs?.filter((t) => t.key !== data.key && !t.pinned); + const deleteOtherTab = async (data: ITabItem) => { + const newInternalTabs = internalTabs?.filter((t) => t.key === data.key || t.pinned || t.canClosed === false); + const deleteTabs = internalTabs?.filter((t) => t.key !== data.key && !t.pinned && t.canClosed !== false); + if (beforeRemove && !(await beforeRemove(deleteTabs))) { + return; + } changeTab(data.key); setInternalTabs(newInternalTabs); onEdit?.('remove', deleteTabs, newInternalTabs); }; // Close all tabs. - const deleteAllTab = () => { - const deleteTabs = internalTabs.filter((tab) => !tab.pinned); - const newInternalTabs = internalTabs.filter((tab) => tab.pinned); + const deleteAllTab = async () => { + const deleteTabs = internalTabs.filter((tab) => !tab.pinned && tab.canClosed !== false); + const newInternalTabs = internalTabs.filter((tab) => tab.pinned || tab.canClosed === false); + if (beforeRemove && !(await beforeRemove(deleteTabs))) { + return; + } changeTab(newInternalTabs[0]?.key ?? null); setInternalTabs(newInternalTabs); onEdit?.('remove', deleteTabs, newInternalTabs); }; + closeActiveTabOnShortcutRef.current = () => { + const tabBox = tabBoxRef.current; + const tabBoxRect = tabBox?.getBoundingClientRect(); + if (!tabBox || !tabBoxRect?.width || !tabBoxRect.height) { + return 'inactive'; + } + const activeTab = internalTabs.find((tab) => tab.key === activeKey); + if (!activeTab || !showClosed(activeTab)) { + return 'pass-through'; + } + deleteTab(activeTab); + return 'closed'; + }; + + useEffect(() => { + if (!closeActiveTabOnCloseShortcut) { + return undefined; + } + return registerCloseActiveResultTabHandler(() => closeActiveTabOnShortcutRef.current()); + }, [closeActiveTabOnCloseShortcut]); + const changeTab = (key: string | number | null) => { onChange(key); }; @@ -410,7 +501,12 @@ export default memo((props) => { createContextMenuAction({ id: 'close', label: i18n('common.button.close'), - shortcutAction: tab.key === activeKey ? ShortcutAction.CloseCurrentConsole : undefined, + shortcutAction: + closeShortcutAction && + (tab.key === activeKey || + (activateOnContextMenu && tab.key === contextMenu?.targetSnapshot.key)) + ? closeShortcutAction + : undefined, execute: () => deleteTab(tab), }), createContextMenuAction({ @@ -586,6 +682,12 @@ export default memo((props) => { function handleContextMenu(event: React.MouseEvent) { event.preventDefault(); event.stopPropagation(); + if (!createTabContextMenuActions(t).some(isContextMenuAction)) { + return; + } + if (activateOnContextMenu && t.key !== activeKey) { + changeTab(t.key); + } setContextMenu({ surface: 'workspaceTab', pointer: { @@ -831,6 +933,7 @@ export default memo((props) => {
)}
+ {tabBarExtraContent &&
{tabBarExtraContent}
}
((props) => { placement="bottomRight" trigger={['click']} > -
- -
+
@@ -862,7 +973,7 @@ export default memo((props) => { } return ( -
+
((props) => { {!destroyInactiveTabPane ? (
{internalTabs?.map((t) => { + if (t.destroyOnHide && t.key !== activeKey) { + return null; + } return (
{ const { colorBgBase, colorPrimary, colorText } = token; const colorBorderLayout = (token as any).colorBorderLayout || token.colorBorderSecondary; + const moreTabsWidth = showTabBarExtraContent ? 29 : 34; + const tabBarExtraWidth = showTabBarExtraContent ? 29 : 0; const activeTab = css` &:after { content: ''; @@ -96,7 +110,9 @@ export const useStyles = createStyles( tabList: css` display: flex; position: relative; - max-width: ${showAddButton ? 'calc(100% - 68px)' : 'calc(100% - 34px)'}; + max-width: calc( + 100% - ${moreTabsWidth + (showAddButton ? 34 : 0) + tabBarExtraWidth}px + ); width: fit-content; flex-shrink: 0; overflow-x: auto; @@ -140,6 +156,14 @@ export const useStyles = createStyles( position: relative; display: flex; align-items: center; + ${uniformTabWidth + ? ` + box-sizing: border-box; + flex: 0 0 ${tabMaxWidth}; + width: ${tabMaxWidth}; + max-width: ${tabMaxWidth}; + ` + : ''} padding-left: 10px; cursor: pointer; user-select: none; @@ -253,26 +277,38 @@ export const useStyles = createStyles( display: block; `, moreTabs: css` - width: 34px; + width: ${moreTabsWidth}px; flex-shrink: 0; height: 100%; display: flex; - justify-content: center; + justify-content: ${showTabBarExtraContent ? 'flex-start' : 'center'}; align-items: center; `, - moreTabsButton: css` - width: 24px; - height: 24px; - border-radius: 8px; - background-color: ${token.colorFillSecondary}; + tabBarExtra: css` + width: ${tabBarExtraWidth}px; + flex-shrink: 0; + height: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + `, + iconButtonTrigger: css` display: flex; - justify-content: center; align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin: 0; + padding: 0; + border: 0; + border-radius: 4px; + outline: none; + color: inherit; + background: transparent; cursor: pointer; - color: ${token.colorTextTertiary}; - &:hover { - color: #fff; - background-color: ${token.colorPrimary}; + + &:focus-visible { + box-shadow: inset 0 0 0 2px ${token.colorPrimary}; } `, moreTabsBox: css` diff --git a/chat2db-community-client/src/components/Xterm/index.tsx b/chat2db-community-client/src/components/Xterm/index.tsx index f5c17bb80b..fc2ad1e3de 100644 --- a/chat2db-community-client/src/components/Xterm/index.tsx +++ b/chat2db-community-client/src/components/Xterm/index.tsx @@ -1,83 +1,136 @@ import React, { useEffect, useImperativeHandle, ForwardedRef, forwardRef, useCallback } from 'react'; import { useStyles } from './style'; import classnames from 'classnames'; -import { Terminal } from 'xterm'; -import { FitAddon } from 'xterm-addon-fit'; // Fits the terminal to its container. -// import { WebLinksAddon } from 'xterm-addon-web-links'; -import 'xterm/css/xterm.css'; // Import styles. +import { ITheme, Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { SerializeAddon } from '@xterm/addon-serialize'; +import '@xterm/xterm/css/xterm.css'; +import { getPersistentTerminalBuffer, setPersistentTerminalBuffer } from '@/utils/terminalBuffer'; interface IProps { className?: string; value?: string; xtermHeaderSlot?: React.ReactNode; + onData?: (data: string) => void; + onResize?: (columns: number, rows: number) => void; + readOnly?: boolean; + theme?: ITheme; + persistenceKey?: string; } export interface IXtermRef { - xtermWrite: (value: string) => void; + xtermWrite: (value: string, callback?: () => void) => void; } export default forwardRef((props: IProps, ref: ForwardedRef) => { const { styles } = useStyles(); const terminalRef = React.useRef(null); - const { className, xtermHeaderSlot } = props; - const xtermRef = React.useRef(null); - const fitAddonRef = React.useRef(null); + const { className, xtermHeaderSlot, onData, onResize, readOnly = true, theme, persistenceKey } = props; + const xtermRef = React.useRef(null); + const fitAddonRef = React.useRef(null); + const serializeAddonRef = React.useRef(null); + const resizeObserverRef = React.useRef(null); + const resizeFrameRef = React.useRef(); + const lastSizeRef = React.useRef<{ columns: number; rows: number }>(); + const onDataRef = React.useRef(onData); + const onResizeRef = React.useRef(onResize); + + onDataRef.current = onData; + onResizeRef.current = onResize; const resizeFitAddon = useCallback(() => { - fitAddonRef.current?.fit(); + if (resizeFrameRef.current !== undefined) { + return; + } + resizeFrameRef.current = window.requestAnimationFrame(() => { + resizeFrameRef.current = undefined; + fitAddonRef.current?.fit(); + const terminal = xtermRef.current; + if (!terminal) { + return; + } + const nextSize = { columns: terminal.cols, rows: terminal.rows }; + const lastSize = lastSizeRef.current; + if (lastSize?.columns === nextSize.columns && lastSize.rows === nextSize.rows) { + return; + } + lastSizeRef.current = nextSize; + onResizeRef.current?.(nextSize.columns, nextSize.rows); + }); }, []); const initXterm = () => { const xterm = new Terminal({ convertEol: true, // Move the cursor to the start of the next line on EOL. - disableStdin: true, // Disable input. + disableStdin: readOnly, cursorStyle: 'block', // Cursor style. - cursorBlink: false, // Cursor blinking. - theme: { - foreground: '#f6f8fa', // Foreground. - background: '#24292f', // Background. - }, + cursorBlink: !readOnly, + theme, // Set the font. fontSize: 14, }); fitAddonRef.current = new FitAddon(); + serializeAddonRef.current = new SerializeAddon(); xterm.loadAddon(fitAddonRef.current); + xterm.loadAddon(serializeAddonRef.current); xterm.open(terminalRef.current!); - fitAddonRef.current.fit(); - xterm.onData((data) => { - let dataWrapper = data; - if (dataWrapper === '\r') { - dataWrapper = '\n'; - } else if (dataWrapper === '\u0003') { - dataWrapper += '\n'; - } else if (dataWrapper === '\u007F') { - // ASCII DEL character - // Delete the last character. - xterm.write('\b \b'); - return; - } - xterm.write(dataWrapper); - }); + xterm.onData((data) => onDataRef.current?.(data)); xtermRef.current = xterm; + if (persistenceKey) { + const persistedBuffer = getPersistentTerminalBuffer(persistenceKey); + if (persistedBuffer) { + xterm.write(persistedBuffer); + } + } + resizeFitAddon(); - window.addEventListener('resize', resizeFitAddon); + const resizeObserver = new ResizeObserver(resizeFitAddon); + resizeObserver.observe(terminalRef.current!); + resizeObserverRef.current = resizeObserver; return xterm; }; useEffect(() => { initXterm(); return () => { - window.removeEventListener('resize', resizeFitAddon); + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + if (resizeFrameRef.current !== undefined) { + window.cancelAnimationFrame(resizeFrameRef.current); + resizeFrameRef.current = undefined; + } + if (persistenceKey && serializeAddonRef.current) { + setPersistentTerminalBuffer(persistenceKey, serializeAddonRef.current.serialize()); + } // Dispose the Terminal (and its loaded FitAddon / onData listener) // so the instance, DOM, and renderer are torn down on unmount. xtermRef.current?.dispose(); xtermRef.current = null; fitAddonRef.current = null; + serializeAddonRef.current = null; + lastSizeRef.current = undefined; }; - }, []); + }, [persistenceKey, resizeFitAddon]); + + useEffect(() => { + if (xtermRef.current) { + xtermRef.current.options.disableStdin = readOnly; + xtermRef.current.options.cursorBlink = !readOnly; + } + }, [readOnly]); + + useEffect(() => { + if (xtermRef.current) { + xtermRef.current.options.theme = theme; + } + }, [theme]); - const xtermWrite = (value: string) => { - xtermRef.current.write(value); + const xtermWrite = (value: string, callback?: () => void) => { + if (xtermRef.current) { + xtermRef.current.write(value, callback); + } else { + callback?.(); + } }; useImperativeHandle(ref, () => ({ @@ -85,7 +138,10 @@ export default forwardRef((props: IProps, ref: ForwardedRef) => { })); return ( -
+
{xtermHeaderSlot}
diff --git a/chat2db-community-client/src/constants/shortcut.test.ts b/chat2db-community-client/src/constants/shortcut.test.ts new file mode 100644 index 0000000000..f7a2da802a --- /dev/null +++ b/chat2db-community-client/src/constants/shortcut.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; + +const globalObj = globalThis as unknown as Record; +globalObj.__RUNTIME_ENV__ = 'community'; +globalObj.__ENV__ = 'test'; +globalObj.window = {}; + +if (typeof globalThis.navigator === 'undefined') { + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'Mac' }, + configurable: true, + }); +} + +async function runShortcutTests() { + const { + ShortcutAction, + DEFAULT_SHORTCUT_CONFIG, + getEffectiveShortcutConfig, + getEventShortcutBinding, + isShortcutCaptureAllowed, + shortcutBindingToMonacoKeybinding, + } = await import('./shortcut'); + + // 1. Verify Default Shortcuts for Line Comment and Block Comment + type DefaultConfigKey = keyof typeof DEFAULT_SHORTCUT_CONFIG; + const lineKey = ShortcutAction.SqlToggleLineComment as DefaultConfigKey; + const defaultLineComment = DEFAULT_SHORTCUT_CONFIG[lineKey]; + assert.equal(defaultLineComment.action, ShortcutAction.SqlToggleLineComment); + assert.equal(defaultLineComment.canModify, true); + + const blockKey = ShortcutAction.SqlToggleBlockComment as DefaultConfigKey; + const defaultBlockComment = DEFAULT_SHORTCUT_CONFIG[blockKey]; + assert.equal(defaultBlockComment.action, ShortcutAction.SqlToggleBlockComment); + assert.equal(defaultBlockComment.canModify, true); + + // 2. Test getEventShortcutBinding with physical key (event.code) on shifted punctuation key + const fakeSlashShiftEvent = { + code: 'Slash', + key: '?', + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + } as unknown as KeyboardEvent; + + const bindingFromEvent = getEventShortcutBinding(fakeSlashShiftEvent); + assert.equal(bindingFromEvent, 'Ctrl + Shift + /'); + + // Parsing and capture policy are separate: printable keys need a modifier to be saved from Settings. + assert.equal(isShortcutCaptureAllowed(['a']), false); + assert.equal(isShortcutCaptureAllowed(['/']), false); + assert.equal(isShortcutCaptureAllowed(['control']), false); + assert.equal(isShortcutCaptureAllowed(['f5']), true); + assert.equal(isShortcutCaptureAllowed(['control', 'shift', '?']), true); + + // 3. Test shortcutBindingToMonacoKeybinding with mock Monaco instance + const mockMonaco = { + KeyMod: { + CtrlCmd: 2048, + Shift: 1024, + Alt: 512, + }, + KeyCode: { + Slash: 85, + KeyC: 33, + Semicolon: 80, + }, + }; + + const lineBinding = defaultLineComment.defaultBinding; + const defaultLineKeybinding = shortcutBindingToMonacoKeybinding(lineBinding, mockMonaco); + assert.notEqual(defaultLineKeybinding, null); + + const blockBinding = defaultBlockComment.defaultBinding; + const defaultBlockKeybinding = shortcutBindingToMonacoKeybinding(blockBinding, mockMonaco); + assert.notEqual(defaultBlockKeybinding, null); + const expectedBlock = mockMonaco.KeyMod.CtrlCmd | mockMonaco.KeyMod.Shift | mockMonaco.KeyCode.Slash; + assert.equal(defaultBlockKeybinding, expectedBlock); + + // 4. Test Remapping / Override Config + const overrides = { + [ShortcutAction.SqlToggleLineComment]: { + binding: 'Ctrl + Shift + C', + }, + }; + + const remappedConfig = getEffectiveShortcutConfig(ShortcutAction.SqlToggleLineComment, overrides); + assert.equal(remappedConfig.binding, 'Ctrl + Shift + C'); + assert.equal(remappedConfig.isDefault, false); + + const remappedKeybinding = shortcutBindingToMonacoKeybinding(remappedConfig.binding, mockMonaco); + const expectedRemapped = mockMonaco.KeyMod.CtrlCmd | mockMonaco.KeyMod.Shift | mockMonaco.KeyCode.KeyC; + assert.equal(remappedKeybinding, expectedRemapped); + + console.log('All shortcut tests passed successfully!'); +} + +runShortcutTests().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/chat2db-community-client/src/constants/shortcut.ts b/chat2db-community-client/src/constants/shortcut.ts index b1481f0a4b..367d52f7ea 100644 --- a/chat2db-community-client/src/constants/shortcut.ts +++ b/chat2db-community-client/src/constants/shortcut.ts @@ -4,6 +4,7 @@ export enum ShortcutScope { Global = 'global', SqlEditor = 'sqlEditor', LocalSqlFileTree = 'localSqlFileTree', + DatabaseTree = 'databaseTree', ResultSet = 'resultSet', Workspace = 'workspace', Table = 'table', @@ -22,6 +23,10 @@ export enum ShortcutAction { ArouseAIAssistant = 'arouseAIAssistant', NewAIChat = 'newAIChat', WorkspaceTreeSearch = 'workspaceTreeSearch', + DatabaseTreeOpenTable = 'databaseTreeOpenTable', + DatabaseTreeEditTable = 'databaseTreeEditTable', + DatabaseTreeCreateTable = 'databaseTreeCreateTable', + DatabaseTreeRefresh = 'databaseTreeRefresh', LocalSqlFileTreeOpenFolder = 'localSqlFileTreeOpenFolder', LocalSqlFileTreeAddFolder = 'localSqlFileTreeAddFolder', LocalSqlFileTreeOpen = 'localSqlFileTreeOpen', @@ -41,6 +46,8 @@ export enum ShortcutAction { SqlCut = 'sqlCut', SqlCaseConvert = 'sqlCaseConvert', SqlFormat = 'sqlFormat', + SqlToggleLineComment = 'sqlToggleLineComment', + SqlToggleBlockComment = 'sqlToggleBlockComment', SqlSave = 'sqlSave', SqlSaveToDesktop = 'sqlSaveToDesktop', SqlExecuteCurrent = 'sqlExecuteCurrent', @@ -187,6 +194,38 @@ export const DEFAULT_SHORTCUT_CONFIG: Record allowInEditable: true, canModify: true, }, + [ShortcutAction.DatabaseTreeOpenTable]: { + key: ShortcutAction.DatabaseTreeOpenTable, + label: 'setting.shortcut.databaseTreeOpenTable', + action: ShortcutAction.DatabaseTreeOpenTable, + defaultBinding: `${modifierKey} + O`, + scope: ShortcutScope.DatabaseTree, + canModify: true, + }, + [ShortcutAction.DatabaseTreeEditTable]: { + key: ShortcutAction.DatabaseTreeEditTable, + label: 'setting.shortcut.databaseTreeEditTable', + action: ShortcutAction.DatabaseTreeEditTable, + defaultBinding: `${modifierKey} + D`, + scope: ShortcutScope.DatabaseTree, + canModify: true, + }, + [ShortcutAction.DatabaseTreeCreateTable]: { + key: ShortcutAction.DatabaseTreeCreateTable, + label: 'setting.shortcut.databaseTreeCreateTable', + action: ShortcutAction.DatabaseTreeCreateTable, + defaultBinding: `${modifierKey} + N`, + scope: ShortcutScope.DatabaseTree, + canModify: true, + }, + [ShortcutAction.DatabaseTreeRefresh]: { + key: ShortcutAction.DatabaseTreeRefresh, + label: 'setting.shortcut.databaseTreeRefresh', + action: ShortcutAction.DatabaseTreeRefresh, + defaultBinding: `${modifierKey} + R`, + scope: ShortcutScope.DatabaseTree, + canModify: true, + }, [ShortcutAction.LocalSqlFileTreeOpenFolder]: { key: ShortcutAction.LocalSqlFileTreeOpenFolder, label: 'setting.shortcut.localSqlFileTreeOpenFolder', @@ -344,6 +383,24 @@ export const DEFAULT_SHORTCUT_CONFIG: Record allowInEditable: true, canModify: true, }, + [ShortcutAction.SqlToggleLineComment]: { + key: ShortcutAction.SqlToggleLineComment, + label: 'setting.shortcut.sqlToggleLineComment', + action: ShortcutAction.SqlToggleLineComment, + defaultBinding: `${modifierKey} + /`, + scope: ShortcutScope.SqlEditor, + allowInEditable: true, + canModify: true, + }, + [ShortcutAction.SqlToggleBlockComment]: { + key: ShortcutAction.SqlToggleBlockComment, + label: 'setting.shortcut.sqlToggleBlockComment', + action: ShortcutAction.SqlToggleBlockComment, + defaultBinding: `${modifierKey} + Shift + /`, + scope: ShortcutScope.SqlEditor, + allowInEditable: true, + canModify: true, + }, [ShortcutAction.SqlSave]: { key: ShortcutAction.SqlSave, label: 'setting.shortcut.sqlSave', @@ -456,6 +513,25 @@ export const DEFAULT_SHORTCUT_CONFIG: Record const MODIFIER_ORDER = isMac ? ['meta', 'ctrl', 'alt', 'shift'] : ['ctrl', 'meta', 'alt', 'shift']; const MODIFIER_KEYS = new Set(['control', 'ctrl', 'meta', 'cmd', 'command', '⌘', 'alt', 'option', '⌥', 'shift', '⇧']); +const SINGLE_KEY_SHORTCUTS = new Set([ + 'enter', + 'escape', + 'tab', + 'backspace', + 'delete', + 'f1', + 'f2', + 'f3', + 'f4', + 'f5', + 'f6', + 'f7', + 'f8', + 'f9', + 'f10', + 'f11', + 'f12', +]); const NON_PRINTABLE_KEY_LABELS: Record = { ' ': 'Space', spacebar: 'Space', @@ -600,6 +676,13 @@ export const getEventShortcutBinding = (event: KeyboardEvent | React.KeyboardEve return normalizeShortcutBinding(keys.join(' + ')); }; +export const isShortcutCaptureAllowed = (pressedKeys: Iterable): boolean => { + const keys = Array.from(pressedKeys, (key) => key.toLowerCase()); + const hasModifierKey = keys.some((key) => MODIFIER_KEYS.has(key)); + const isAllowedSingleKey = keys.length === 1 && SINGLE_KEY_SHORTCUTS.has(keys[0]); + return isAllowedSingleKey || (keys.length > 1 && hasModifierKey); +}; + export const isShortcutBindingEqual = (left?: string | null, right?: string | null): boolean => normalizeShortcutBinding(left) === normalizeShortcutBinding(right); diff --git a/chat2db-community-client/src/constants/terminal.test.ts b/chat2db-community-client/src/constants/terminal.test.ts new file mode 100644 index 0000000000..37c6efad74 --- /dev/null +++ b/chat2db-community-client/src/constants/terminal.test.ts @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { DEFAULT_TERMINAL_SETTINGS, isTerminalCloseConfirmationEnabled } from './terminal'; + +assert.equal(DEFAULT_TERMINAL_SETTINGS.confirmBeforeClose, true); +assert.equal(isTerminalCloseConfirmationEnabled(), true); +assert.equal(isTerminalCloseConfirmationEnabled({}), true); +assert.equal(isTerminalCloseConfirmationEnabled({ confirmBeforeClose: true }), true); +assert.equal(isTerminalCloseConfirmationEnabled({ confirmBeforeClose: false }), false); + +console.log('Terminal settings tests passed'); diff --git a/chat2db-community-client/src/constants/terminal.ts b/chat2db-community-client/src/constants/terminal.ts new file mode 100644 index 0000000000..7a1dd5ee08 --- /dev/null +++ b/chat2db-community-client/src/constants/terminal.ts @@ -0,0 +1,159 @@ +import type { ITheme } from 'xterm'; +import type { TerminalSettings, TerminalThemeId } from '@/typings/settings'; + +export interface TerminalThemeConfig { + id: TerminalThemeId; + name: string; + theme: ITheme; +} + +export const TERMINAL_THEMES: Record = { + 'chat2db-dark': { + id: 'chat2db-dark', + name: 'Chat2DB Dark', + theme: { + background: '#161b22', + foreground: '#e6edf3', + cursor: '#58a6ff', + cursorAccent: '#161b22', + selectionBackground: '#264f78', + black: '#484f58', + red: '#ff7b72', + green: '#3fb950', + yellow: '#d29922', + blue: '#58a6ff', + magenta: '#bc8cff', + cyan: '#39c5cf', + white: '#b1bac4', + brightBlack: '#6e7681', + brightRed: '#ffa198', + brightGreen: '#56d364', + brightYellow: '#e3b341', + brightBlue: '#79c0ff', + brightMagenta: '#d2a8ff', + brightCyan: '#56d4dd', + brightWhite: '#f0f6fc', + }, + }, + 'one-dark': { + id: 'one-dark', + name: 'One Dark', + theme: { + background: '#282c34', + foreground: '#abb2bf', + cursor: '#528bff', + cursorAccent: '#282c34', + selectionBackground: '#3e4451', + black: '#5c6370', + red: '#e06c75', + green: '#98c379', + yellow: '#e5c07b', + blue: '#61afef', + magenta: '#c678dd', + cyan: '#56b6c2', + white: '#abb2bf', + brightBlack: '#7f848e', + brightRed: '#e06c75', + brightGreen: '#98c379', + brightYellow: '#e5c07b', + brightBlue: '#61afef', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#ffffff', + }, + }, + dracula: { + id: 'dracula', + name: 'Dracula', + theme: { + background: '#282a36', + foreground: '#f8f8f2', + cursor: '#f8f8f2', + cursorAccent: '#282a36', + selectionBackground: '#44475a', + black: '#21222c', + red: '#ff5555', + green: '#50fa7b', + yellow: '#f1fa8c', + blue: '#bd93f9', + magenta: '#ff79c6', + cyan: '#8be9fd', + white: '#f8f8f2', + brightBlack: '#6272a4', + brightRed: '#ff6e6e', + brightGreen: '#69ff94', + brightYellow: '#ffffa5', + brightBlue: '#d6acff', + brightMagenta: '#ff92df', + brightCyan: '#a4ffff', + brightWhite: '#ffffff', + }, + }, + 'solarized-dark': { + id: 'solarized-dark', + name: 'Solarized Dark', + theme: { + background: '#002b36', + foreground: '#839496', + cursor: '#93a1a1', + cursorAccent: '#002b36', + selectionBackground: '#073642', + black: '#073642', + red: '#dc322f', + green: '#859900', + yellow: '#b58900', + blue: '#268bd2', + magenta: '#d33682', + cyan: '#2aa198', + white: '#eee8d5', + brightBlack: '#586e75', + brightRed: '#cb4b16', + brightGreen: '#586e75', + brightYellow: '#657b83', + brightBlue: '#839496', + brightMagenta: '#6c71c4', + brightCyan: '#93a1a1', + brightWhite: '#fdf6e3', + }, + }, + 'solarized-light': { + id: 'solarized-light', + name: 'Solarized Light', + theme: { + background: '#fdf6e3', + foreground: '#657b83', + cursor: '#586e75', + cursorAccent: '#fdf6e3', + selectionBackground: '#eee8d5', + black: '#073642', + red: '#dc322f', + green: '#859900', + yellow: '#b58900', + blue: '#268bd2', + magenta: '#d33682', + cyan: '#2aa198', + white: '#eee8d5', + brightBlack: '#002b36', + brightRed: '#cb4b16', + brightGreen: '#586e75', + brightYellow: '#657b83', + brightBlue: '#839496', + brightMagenta: '#6c71c4', + brightCyan: '#93a1a1', + brightWhite: '#fdf6e3', + }, + }, +}; + +export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = { + shellId: 'system', + themeId: 'chat2db-dark', + openPosition: 'tab', + confirmBeforeClose: true, +}; + +export const isTerminalCloseConfirmationEnabled = (settings?: Partial) => + settings?.confirmBeforeClose ?? DEFAULT_TERMINAL_SETTINGS.confirmBeforeClose; + +export const getTerminalTheme = (themeId?: TerminalThemeId) => + TERMINAL_THEMES[themeId || DEFAULT_TERMINAL_SETTINGS.themeId] || TERMINAL_THEMES['chat2db-dark']; diff --git a/chat2db-community-client/src/constants/workspace.ts b/chat2db-community-client/src/constants/workspace.ts index a4021311d6..7630b1f4fb 100644 --- a/chat2db-community-client/src/constants/workspace.ts +++ b/chat2db-community-client/src/constants/workspace.ts @@ -35,6 +35,7 @@ export enum WorkspaceTabType { RedisAllData = 'redisAllData', AccountPrivileges = 'accountPrivileges', ContentDiff = 'contentDiff', + Terminal = 'terminal', } // Some configurations corresponding to the type of workbench Tab @@ -94,6 +95,9 @@ export const workspaceTabConfig: { [WorkspaceTabType.ContentDiff]: { icon: 'icon-switch-horizontal', }, + [WorkspaceTabType.Terminal]: { + icon: 'icon-terminal', + }, [WorkspaceTabType.ViewERModal]: { icon: 'icon-er-modal', }, diff --git a/chat2db-community-client/src/hooks/useSqlExecutor.ts b/chat2db-community-client/src/hooks/useSqlExecutor.ts index 670ffefe36..4e9ec873c6 100644 --- a/chat2db-community-client/src/hooks/useSqlExecutor.ts +++ b/chat2db-community-client/src/hooks/useSqlExecutor.ts @@ -5,7 +5,7 @@ import useAbortRequest from './useAbortRequest'; import { isDesktop } from '@/utils/env'; import { SqlExecutionEvent, - cancelSqlExecution, + cancelSqlExecutionWithReconciliation, onSqlExecutionEvent, startSqlExecution, } from '@/service/sqlExecutionStream'; @@ -14,10 +14,14 @@ import { useGlobalStore } from '@/store/global'; import { settingSelectors } from '@/store/global/selectors'; import { beginSqlExecutionRequest, + canBeginSqlExecutionRequest, createSqlExecutionRequestTracker, + finalizeSqlExecutionRequest, finishSqlExecutionRequest, - getActiveSqlExecutionId, + isSqlExecutionCancellationRequested, + requestSqlExecutionCancellation, setSqlExecutionRequestId, + SqlExecutionBusyError, SqlExecutionRequestTracker, } from '@/service/sqlExecutionRequestTracker'; @@ -25,28 +29,76 @@ interface IUseSqlExecutorProps { // Whether to return only one piece of data onlyOne?: boolean; onExecutionRequestStart?: (requestSequence: number) => void; + onExecutionRequestStartError?: ( + error: unknown, + requestSequence: number, + params: IExecuteSqlParams, + ) => void; onExecutionEvent?: (event: SqlExecutionEvent, requestSequence: number) => void; + onExecutionCancellationError?: (error: unknown) => void; +} + +interface DesktopExecutionControl { + requestSequence: number; + unsubscribe?: () => void; + resolve: (results: IManageResultData[]) => void; } const useSqlExecutor = (props?: IUseSqlExecutorProps) => { - const { onlyOne, onExecutionRequestStart, onExecutionEvent } = props || {}; + const { + onlyOne, + onExecutionRequestStart, + onExecutionRequestStartError, + onExecutionEvent, + onExecutionCancellationError, + } = props || {}; const defaultPageSize = useGlobalStore((state) => settingSelectors.currentBaseSetting(state).defaultPageSize); const [executing, setExecuting] = useState(false); const executionRequestTrackerRef = useRef(); if (!executionRequestTrackerRef.current) { executionRequestTrackerRef.current = createSqlExecutionRequestTracker(); } + const desktopExecutionControlRef = useRef(); // interrupt request const [initSignal, abortRequest] = useAbortRequest(); + const canExecuteSQL = useCallback( + () => canBeginSqlExecutionRequest(executionRequestTrackerRef.current!), + [], + ); - // Process data - const handleData = (params: { data: any[] }) => { - const { data } = params; - if (onlyOne) { - return data[0] ? [data[0]] : []; - } - return data; - }; + const requestDesktopCancellation = useCallback( + (executionId: string, requestSequence: number) => { + void cancelSqlExecutionWithReconciliation(executionId, { + onExecutionMissing: () => { + const control = desktopExecutionControlRef.current; + if (!control || control.requestSequence !== requestSequence) { + return; + } + try { + onExecutionEvent?.( + { + executionId, + occurredAtEpochMs: Date.now(), + eventType: 'cancelled', + message: {}, + }, + requestSequence, + ); + } finally { + control.unsubscribe?.(); + finishSqlExecutionRequest(executionRequestTrackerRef.current!, requestSequence); + desktopExecutionControlRef.current = undefined; + setExecuting(false); + control.resolve([]); + } + }, + onError: (error) => { + onExecutionCancellationError?.(error); + }, + }); + }, + [onExecutionCancellationError, onExecutionEvent], + ); // execute sql const executeSQL = useCallback((params: IExecuteSqlParams): Promise => { @@ -55,91 +107,135 @@ const useSqlExecutor = (props?: IUseSqlExecutorProps) => { pageNo: params.pageNo ?? 1, pageSize: params.pageSize ?? defaultPageSize, }; + const executionRequestTracker = executionRequestTrackerRef.current; + const requestSequence = beginSqlExecutionRequest(executionRequestTracker); + if (requestSequence === undefined) { + return Promise.reject(new SqlExecutionBusyError()); + } if (isDesktop && onExecutionEvent) { const requestUuid = uuidv4(); - const executionRequestTracker = executionRequestTrackerRef.current; - const requestSequence = beginSqlExecutionRequest(executionRequestTracker); - onExecutionRequestStart?.(requestSequence); setExecuting(true); return new Promise((resolve, reject) => { const subscription: { unsubscribe?: () => void } = {}; - subscription.unsubscribe = onSqlExecutionEvent(requestUuid, (event) => { - onExecutionEvent(event, requestSequence); - if (event.eventType === 'finished') { - subscription.unsubscribe?.(); - if (finishSqlExecutionRequest(executionRequestTracker, requestSequence)) { - setExecuting(false); + const control: DesktopExecutionControl = { requestSequence, resolve }; + desktopExecutionControlRef.current = control; + const clearControl = () => { + if (desktopExecutionControlRef.current === control) { + desktopExecutionControlRef.current = undefined; + } + }; + const rejectStart = (error: unknown) => { + subscription.unsubscribe?.(); + clearControl(); + let rejection = error; + if (finishSqlExecutionRequest(executionRequestTracker, requestSequence)) { + setExecuting(false); + try { + onExecutionRequestStartError?.(error, requestSequence, executeSqlParams); + } catch (callbackError) { + rejection = callbackError; } - resolve([]); } - if (event.eventType === 'failed' || event.eventType === 'cancelled') { + reject(rejection); + }; + try { + onExecutionRequestStart?.(requestSequence); + subscription.unsubscribe = onSqlExecutionEvent(requestUuid, (event) => { + const terminalEvent = + event.eventType === 'finished' || + event.eventType === 'failed' || + event.eventType === 'cancelled'; + if (!terminalEvent) { + onExecutionEvent(event, requestSequence); + return; + } + + let callbackError: unknown; + try { + onExecutionEvent(event, requestSequence); + } catch (error) { + callbackError = error; + } subscription.unsubscribe?.(); + clearControl(); if (finishSqlExecutionRequest(executionRequestTracker, requestSequence)) { setExecuting(false); } - if (event.eventType === 'cancelled') { - resolve([]); - } else { + if (callbackError !== undefined) { + reject(callbackError); + } else if (event.eventType === 'failed') { reject(event.message); + } else { + resolve([]); } - } - }); - startSqlExecution(executeSqlParams, requestUuid) + }); + } catch (error) { + rejectStart(error); + return; + } + control.unsubscribe = subscription.unsubscribe; + Promise.resolve() + .then(() => startSqlExecution(executeSqlParams, requestUuid)) .then((res) => { if (!res?.executionId) { - subscription.unsubscribe?.(); - if (finishSqlExecutionRequest(executionRequestTracker, requestSequence)) { - setExecuting(false); - } - reject(getStartExecutionError(res)); + rejectStart(getStartExecutionError(res)); return; } - setSqlExecutionRequestId(executionRequestTracker, requestSequence, res.executionId); + const executionIdAttached = setSqlExecutionRequestId( + executionRequestTracker, + requestSequence, + res.executionId, + ); + if ( + executionIdAttached && + isSqlExecutionCancellationRequested(executionRequestTracker, requestSequence) + ) { + requestDesktopCancellation(res.executionId, requestSequence); + } }) .catch((err) => { - subscription.unsubscribe?.(); - if (finishSqlExecutionRequest(executionRequestTracker, requestSequence)) { - setExecuting(false); - } - reject(err); + rejectStart(err); }); }); } - return new Promise((resolve, reject) => { - // Parameters for executing sql - setExecuting(true); - - // execute sql - return executeSqlServer - .executeSql(executeSqlParams, { + setExecuting(true); + const request = Promise.resolve() + .then(() => + executeSqlServer.executeSql(executeSqlParams, { signal: initSignal(), - }) - .then((res) => { - const data = handleData({ data: res }); - resolve(data); - }) - .catch((err) => { - reject(err); - }) - .finally(() => { - setExecuting(false); - }); + }), + ) + .then((data) => (onlyOne ? (data[0] ? [data[0]] : []) : data)); + return finalizeSqlExecutionRequest(executionRequestTracker, requestSequence, request, () => { + setExecuting(false); }); - }, [defaultPageSize, onExecutionEvent, onExecutionRequestStart]); + }, [ + defaultPageSize, + initSignal, + onlyOne, + onExecutionEvent, + onExecutionRequestStart, + onExecutionRequestStartError, + requestDesktopCancellation, + ]); // Stop executing sql const stopExecuteSQL = useCallback(() => { - const activeExecutionId = getActiveSqlExecutionId(executionRequestTrackerRef.current!); - if (isDesktop && activeExecutionId) { - cancelSqlExecution(activeExecutionId); + const executionRequestTracker = executionRequestTrackerRef.current!; + if (isDesktop && onExecutionEvent) { + const activeRequestSequence = executionRequestTracker.activeRequestSequence; + const activeExecutionId = requestSqlExecutionCancellation(executionRequestTracker); + if (activeExecutionId && activeRequestSequence !== undefined) { + requestDesktopCancellation(activeExecutionId, activeRequestSequence); + } return; } abortRequest(); - setExecuting(false); - }, [abortRequest]); + }, [abortRequest, onExecutionEvent, requestDesktopCancellation]); return { executing, + canExecuteSQL, executeSQL, stopExecuteSQL, }; diff --git a/chat2db-community-client/src/i18n/en-US/common.ts b/chat2db-community-client/src/i18n/en-US/common.ts index 56f059458b..56840bd15d 100644 --- a/chat2db-community-client/src/i18n/en-US/common.ts +++ b/chat2db-community-client/src/i18n/en-US/common.ts @@ -76,6 +76,7 @@ export default { 'common.button.copyName': 'Copy name', 'common.button.copySuccessfully': 'Copy Successfully', 'common.button.createConsole': 'Create Console', + 'common.button.openInNewConsole': 'Open in new console', 'common.button.exportWord': 'Export to Word', 'common.button.exportExcel': 'Export to Excel', 'common.button.exportHtml': 'Export to Html', @@ -228,9 +229,13 @@ export default { 'common.button.clearConsole': 'Clear output', 'common.button.followConsole': 'Follow latest entry', 'common.button.keepHistoryOutput': 'Keep history output', + 'common.button.keepHistoryResult': 'Keep existing results', 'common.text.order': 'Order', 'common.text.oldestFirst': 'Oldest first', 'common.text.newestFirst': 'Newest first', + 'common.text.resultSettings': 'Result settings', + 'common.text.moreTabs': 'More tabs', + 'common.text.cancelRequestFailed': 'Failed to cancel request. Try again.', 'common.text.rowsReturned': '{1} rows returned', 'common.text.executeDuration': 'execute {1} ms', 'common.text.fetchDuration': 'fetch {1} ms', diff --git a/chat2db-community-client/src/i18n/en-US/connection.ts b/chat2db-community-client/src/i18n/en-US/connection.ts index fdc17e194c..76f77837a6 100644 --- a/chat2db-community-client/src/i18n/en-US/connection.ts +++ b/chat2db-community-client/src/i18n/en-US/connection.ts @@ -44,5 +44,9 @@ export default { 'connection.title.importDBeaver': 'Import DBeaver connections', 'connection.import.success': 'Successfully imported {1} connections', 'connection.import.unable.password.tips': 'We cannot import connection passwords. After the import is complete, please re-enter the connection password', + 'connection.export.exporting': 'Preparing connection file...', + 'connection.export.success': 'Connections exported successfully', + 'connection.export.cancelled': 'Export cancelled', + 'connection.export.failed': 'Failed to export connections. Please try again', 'workspace.menu.exportConnections': 'Export connections', }; diff --git a/chat2db-community-client/src/i18n/en-US/monaco.ts b/chat2db-community-client/src/i18n/en-US/monaco.ts index f7bef5f9ac..9c5553eeeb 100644 --- a/chat2db-community-client/src/i18n/en-US/monaco.ts +++ b/chat2db-community-client/src/i18n/en-US/monaco.ts @@ -33,6 +33,8 @@ export default { 'monaco.text.cut': 'Cut', 'monaco.text.caseConvert': 'Case Convert', 'monaco.text.formatSQL': 'Format SQL', + 'monaco.text.toggleLineComment': 'Toggle Line Comment', + 'monaco.text.toggleBlockComment': 'Toggle Block Comment', 'monaco.text.saveSQL': 'Save', 'monaco.text.saveFile': 'Save', 'monaco.text.saveFileToDesktop': 'Save as File', diff --git a/chat2db-community-client/src/i18n/en-US/setting.ts b/chat2db-community-client/src/i18n/en-US/setting.ts index 136f6c7775..103d3eb907 100644 --- a/chat2db-community-client/src/i18n/en-US/setting.ts +++ b/chat2db-community-client/src/i18n/en-US/setting.ts @@ -16,6 +16,23 @@ export default { 'setting.nav.editSetting': 'Editor Settings', 'setting.nav.apiKeys': 'Api Keys', 'setting.nav.editSettingDescribe': 'SQL Editor Settings', + 'setting.nav.terminal': 'Terminal', + 'setting.nav.terminalDescribe': 'Configure terminal position, close behavior, default shell, and colors', + 'setting.terminal.openPosition': 'Open terminals in', + 'setting.terminal.openPositionDescribe': 'Choose where new terminal tabs appear in the workspace.', + 'setting.terminal.openPositionTab': 'Main tabs', + 'setting.terminal.openPositionBottom': 'Bottom panel', + 'setting.terminal.openPositionRight': 'Right panel', + 'setting.terminal.openPositionApplyHint': 'Applies to new terminals. Open terminals stay in their current pane.', + 'setting.terminal.confirmBeforeClose': 'Confirm before closing', + 'setting.terminal.confirmBeforeCloseDescribe': 'Ask for confirmation before closing a running terminal.', + 'setting.terminal.shell': 'Default shell', + 'setting.terminal.shellDescribe': 'Choose the command environment for new terminals. Only installed shells are shown.', + 'setting.terminal.shellApplyHint': 'The shell applies to new terminals. Duplicated terminals keep the source shell.', + 'setting.terminal.theme': 'Terminal theme', + 'setting.terminal.themeDescribe': 'Choose terminal background, text, and ANSI command colors.', + 'setting.terminal.themeApplyHint': 'The theme is applied to all open terminals immediately.', + 'setting.terminal.capabilitiesFailed': 'Unable to read installed shells. Restart the desktop app and try again.', 'setting.nav.mcp': 'MCP Service', 'setting.nav.mcpDescribe': 'Manage local MCP startup', 'setting.nav.networkProxy': 'Network Proxy', @@ -157,6 +174,7 @@ export default { 'setting.shortcut.table.resetAll': 'Reset All', 'setting.shortcut.group.global': 'Global', 'setting.shortcut.group.workspace': 'Workspace', + 'setting.shortcut.group.databaseTree': 'Datasource Tree', 'setting.shortcut.group.localSqlFileTree': 'Text Files', 'setting.shortcut.group.sqlEditor': 'SQL Editor', 'setting.shortcut.group.resultSet': 'Result Set', @@ -170,6 +188,10 @@ export default { 'setting.shortcut.arouseAIAssistant': 'Arouse AI Assistant', 'setting.shortcut.newAIChat': 'New AI Chat', 'setting.shortcut.workspaceTreeSearch': 'Search Datasource Tree', + 'setting.shortcut.databaseTreeOpenTable': 'Open Table', + 'setting.shortcut.databaseTreeEditTable': 'Edit Table', + 'setting.shortcut.databaseTreeCreateTable': 'New Table', + 'setting.shortcut.databaseTreeRefresh': 'Refresh', 'setting.shortcut.localSqlFileTreeOpenFolder': 'Open Folder', 'setting.shortcut.localSqlFileTreeAddFolder': 'Add Folder', 'setting.shortcut.localSqlFileTreeOpen': 'Open Text File', @@ -189,6 +211,8 @@ export default { 'setting.shortcut.sqlCut': 'SQL Editor Cut', 'setting.shortcut.sqlCaseConvert': 'SQL Case Convert', 'setting.shortcut.sqlFormat': 'Format SQL', + 'setting.shortcut.sqlToggleLineComment': 'Toggle Line Comment', + 'setting.shortcut.sqlToggleBlockComment': 'Toggle Block Comment', 'setting.shortcut.sqlSave': 'Save SQL', 'setting.shortcut.sqlSaveToDesktop': 'Save SQL to Local', 'setting.shortcut.sqlExecuteCurrent': 'Execute Current SQL', diff --git a/chat2db-community-client/src/i18n/en-US/workspace.ts b/chat2db-community-client/src/i18n/en-US/workspace.ts index 104775a5f4..a686aa11a9 100644 --- a/chat2db-community-client/src/i18n/en-US/workspace.ts +++ b/chat2db-community-client/src/i18n/en-US/workspace.ts @@ -5,6 +5,9 @@ export default { 'workspace.title.savedConsole': 'Saved console', 'workspace.savedConsole.unknownDataSource': 'Unspecified data source', 'workspace.savedConsole.unknownDatabase': 'Unspecified database', + 'workspace.savedConsole.initialSaveTitle': 'Save SQL', + 'workspace.savedConsole.namePlaceholder': 'Enter a name for this saved SQL', + 'workspace.savedConsole.untitled': 'Untitled SQL', 'workspace.title.ai': 'AI', 'workspace.menu.syncDataBase': 'Sync DataBase', 'workspace.menu.ViewDDL': 'View DDL', @@ -242,6 +245,21 @@ export default { 'workspace.localSqlFileTree.copySuccess': 'Copied', 'workspace.localSqlFileTree.copyFailed': 'Failed to copy', 'workspace.localSqlFileTree.openTerminalFailed': 'Failed to open terminal', + 'workspace.filePreview.mermaidFailed': 'Failed to render Mermaid diagram. Check its syntax.', + 'workspace.filePreview.source': 'Source', + 'workspace.filePreview.preview': 'Preview', + 'workspace.filePreview.split': 'Source and Preview', + 'workspace.filePreview.imageFailed': 'Failed to load image', + 'workspace.filePreview.pdfUnsupported': 'This desktop runtime cannot display PDF files', + 'workspace.filePreview.unsupported': 'Preview is not available for this file', + 'workspace.terminal.title': 'Terminal', + 'workspace.terminal.stop': 'Stop', + 'workspace.terminal.exited': 'Process exited with code', + 'workspace.terminal.closeTitle': 'Close terminal?', + 'workspace.terminal.closeContent': 'Closing this tab will terminate the current terminal process.', + 'workspace.terminal.closeBusyContent': + 'A program is running in this terminal. Closing the tab will terminate the program and terminal process.', + 'workspace.terminal.closeConfirm': 'Close and terminate', 'workspace.localSqlFileTree.revealFailed': 'Failed to reveal file', 'workspace.databaseAccount.title': 'Users', 'workspace.databaseAccount.open': 'Open Users', diff --git a/chat2db-community-client/src/i18n/es-ES/common.ts b/chat2db-community-client/src/i18n/es-ES/common.ts index e3054ff64f..eb679c2157 100644 --- a/chat2db-community-client/src/i18n/es-ES/common.ts +++ b/chat2db-community-client/src/i18n/es-ES/common.ts @@ -76,6 +76,7 @@ export default { 'common.button.copyName': 'Copiar nombre', 'common.button.copySuccessfully': 'Copiado correctamente', 'common.button.createConsole': 'Crear consola', + 'common.button.openInNewConsole': 'Abrir en una consola nueva', 'common.button.exportWord': 'Exportar a Word', 'common.button.exportExcel': 'Exportar a Excel', 'common.button.exportHtml': 'Exportar a HTML', @@ -265,9 +266,13 @@ export default { 'common.button.clearConsole': 'Limpiar salida', 'common.button.followConsole': 'Seguir la última entrada', 'common.button.keepHistoryOutput': 'Conservar el historial de salida', + 'common.button.keepHistoryResult': 'Conservar resultados existentes', 'common.text.order': 'Orden', 'common.text.oldestFirst': 'Antiguos primero', 'common.text.newestFirst': 'Nuevos primero', + 'common.text.resultSettings': 'Configuración de resultados', + 'common.text.moreTabs': 'Más pestañas', + 'common.text.cancelRequestFailed': 'No se pudo cancelar la solicitud. Inténtelo de nuevo.', 'common.text.rowsReturned': '{1} filas devueltas', 'common.text.executeDuration': 'ejecución {1} ms', 'common.text.fetchDuration': 'lectura {1} ms', diff --git a/chat2db-community-client/src/i18n/es-ES/connection.ts b/chat2db-community-client/src/i18n/es-ES/connection.ts index fe6cf57042..46de903b5e 100644 --- a/chat2db-community-client/src/i18n/es-ES/connection.ts +++ b/chat2db-community-client/src/i18n/es-ES/connection.ts @@ -44,5 +44,9 @@ export default { 'connection.title.importDBeaver': 'Importar conexiones de DBeaver', 'connection.import.success': 'Se importaron correctamente {1} conexiones', 'connection.import.unable.password.tips': 'No podemos importar las contraseñas de las conexiones. Cuando termine la importación, vuelva a introducirlas', + 'connection.export.exporting': 'Preparando el archivo de conexiones...', + 'connection.export.success': 'Conexiones exportadas correctamente', + 'connection.export.cancelled': 'Exportación cancelada', + 'connection.export.failed': 'No se pudieron exportar las conexiones. Inténtelo de nuevo', 'workspace.menu.exportConnections': 'Exportar conexiones', }; diff --git a/chat2db-community-client/src/i18n/es-ES/monaco.ts b/chat2db-community-client/src/i18n/es-ES/monaco.ts index e363c7f1a0..9d35a781d3 100644 --- a/chat2db-community-client/src/i18n/es-ES/monaco.ts +++ b/chat2db-community-client/src/i18n/es-ES/monaco.ts @@ -36,6 +36,8 @@ export default { 'monaco.text.cut': 'Cortar', 'monaco.text.caseConvert': 'Cambiar mayúsculas/minúsculas', 'monaco.text.formatSQL': 'Formatear SQL', + 'monaco.text.toggleLineComment': 'Alternar comentario de línea', + 'monaco.text.toggleBlockComment': 'Alternar comentario en bloque', 'monaco.text.saveSQL': 'Guardar', 'monaco.text.saveFile': 'Guardar', 'monaco.text.saveFileToDesktop': 'Guardar como archivo', diff --git a/chat2db-community-client/src/i18n/es-ES/setting.ts b/chat2db-community-client/src/i18n/es-ES/setting.ts index c7da260d96..f4c0b154f6 100644 --- a/chat2db-community-client/src/i18n/es-ES/setting.ts +++ b/chat2db-community-client/src/i18n/es-ES/setting.ts @@ -16,6 +16,23 @@ export default { 'setting.nav.editSetting': 'Configuración del editor', 'setting.nav.apiKeys': 'Claves API', 'setting.nav.editSettingDescribe': 'Configuración del editor SQL', + 'setting.nav.terminal': 'Terminal', + 'setting.nav.terminalDescribe': 'Configura la posición, la confirmación de cierre, el shell y los colores', + 'setting.terminal.openPosition': 'Abrir terminales en', + 'setting.terminal.openPositionDescribe': 'Elige dónde aparecen las nuevas pestañas de terminal.', + 'setting.terminal.openPositionTab': 'Pestañas principales', + 'setting.terminal.openPositionBottom': 'Panel inferior', + 'setting.terminal.openPositionRight': 'Panel derecho', + 'setting.terminal.openPositionApplyHint': 'Se aplica a terminales nuevas; las abiertas mantienen su posición.', + 'setting.terminal.confirmBeforeClose': 'Confirmar antes de cerrar', + 'setting.terminal.confirmBeforeCloseDescribe': 'Solicita confirmación antes de cerrar un terminal en ejecución.', + 'setting.terminal.shell': 'Shell predeterminado', + 'setting.terminal.shellDescribe': 'Elige el entorno para terminales nuevas. Solo se muestran shells instalados.', + 'setting.terminal.shellApplyHint': 'Se aplica a terminales nuevas; los duplicados conservan el shell de origen.', + 'setting.terminal.theme': 'Tema del terminal', + 'setting.terminal.themeDescribe': 'Elige el fondo, el texto y los colores ANSI.', + 'setting.terminal.themeApplyHint': 'El tema se aplica inmediatamente a todos los terminales abiertos.', + 'setting.terminal.capabilitiesFailed': 'No se pueden leer los shells instalados. Reinicia la aplicación.', 'setting.nav.mcp': 'Servicio MCP', 'setting.nav.mcpDescribe': 'Gestionar el inicio del MCP local', 'setting.nav.networkProxy': 'Proxy de red', @@ -159,6 +176,7 @@ export default { 'setting.shortcut.table.resetAll': 'Restablecer todo', 'setting.shortcut.group.global': 'Global', 'setting.shortcut.group.workspace': 'Espacio de trabajo', + 'setting.shortcut.group.databaseTree': 'Árbol de fuentes de datos', 'setting.shortcut.group.localSqlFileTree': 'Archivos de texto', 'setting.shortcut.group.sqlEditor': 'Editor SQL', 'setting.shortcut.group.resultSet': 'Conjunto de resultados', @@ -172,6 +190,10 @@ export default { 'setting.shortcut.arouseAIAssistant': 'Activar el asistente de IA', 'setting.shortcut.newAIChat': 'Nuevo chat con IA', 'setting.shortcut.workspaceTreeSearch': 'Buscar en el árbol de fuentes de datos', + 'setting.shortcut.databaseTreeOpenTable': 'Abrir tabla', + 'setting.shortcut.databaseTreeEditTable': 'Editar tabla', + 'setting.shortcut.databaseTreeCreateTable': 'Nueva tabla', + 'setting.shortcut.databaseTreeRefresh': 'Actualizar', 'setting.shortcut.localSqlFileTreeOpenFolder': 'Abrir carpeta', 'setting.shortcut.localSqlFileTreeAddFolder': 'Añadir carpeta', 'setting.shortcut.localSqlFileTreeOpen': 'Abrir archivo de texto', @@ -191,6 +213,8 @@ export default { 'setting.shortcut.sqlCut': 'Cortar en el editor SQL', 'setting.shortcut.sqlCaseConvert': 'Cambiar mayúsculas/minúsculas en SQL', 'setting.shortcut.sqlFormat': 'Formatear SQL', + 'setting.shortcut.sqlToggleLineComment': 'Alternar comentario de línea', + 'setting.shortcut.sqlToggleBlockComment': 'Alternar comentario en bloque', 'setting.shortcut.sqlSave': 'Guardar SQL', 'setting.shortcut.sqlSaveToDesktop': 'Guardar SQL en local', 'setting.shortcut.sqlExecuteCurrent': 'Ejecutar el SQL actual', diff --git a/chat2db-community-client/src/i18n/es-ES/workspace.ts b/chat2db-community-client/src/i18n/es-ES/workspace.ts index 39367dc39b..78f6510c2b 100644 --- a/chat2db-community-client/src/i18n/es-ES/workspace.ts +++ b/chat2db-community-client/src/i18n/es-ES/workspace.ts @@ -5,6 +5,9 @@ export default { 'workspace.title.savedConsole': 'Consola guardada', 'workspace.savedConsole.unknownDataSource': 'Fuente de datos sin especificar', 'workspace.savedConsole.unknownDatabase': 'Base de datos sin especificar', + 'workspace.savedConsole.initialSaveTitle': 'Guardar SQL', + 'workspace.savedConsole.namePlaceholder': 'Introduzca un nombre para este SQL guardado', + 'workspace.savedConsole.untitled': 'SQL sin título', 'workspace.title.ai': 'IA', 'workspace.menu.syncDataBase': 'Sincronizar base de datos', 'workspace.menu.ViewDDL': 'Ver DDL', @@ -242,6 +245,21 @@ export default { 'workspace.localSqlFileTree.copySuccess': 'Copiado', 'workspace.localSqlFileTree.copyFailed': 'No se pudo copiar', 'workspace.localSqlFileTree.openTerminalFailed': 'No se pudo abrir la terminal', + 'workspace.filePreview.mermaidFailed': 'No se pudo renderizar el diagrama Mermaid. Revisa la sintaxis.', + 'workspace.filePreview.source': 'Código fuente', + 'workspace.filePreview.preview': 'Vista previa', + 'workspace.filePreview.split': 'Código fuente y vista previa', + 'workspace.filePreview.imageFailed': 'No se pudo cargar la imagen', + 'workspace.filePreview.pdfUnsupported': 'Este entorno de escritorio no puede mostrar archivos PDF', + 'workspace.filePreview.unsupported': 'La vista previa no está disponible para este archivo', + 'workspace.terminal.title': 'Terminal', + 'workspace.terminal.stop': 'Detener', + 'workspace.terminal.exited': 'El proceso terminó con el código', + 'workspace.terminal.closeTitle': '¿Cerrar terminal?', + 'workspace.terminal.closeContent': 'Cerrar esta pestaña finalizará el proceso de terminal actual.', + 'workspace.terminal.closeBusyContent': + 'Hay un programa ejecutándose en esta terminal. Cerrar la pestaña finalizará el programa y el proceso de terminal.', + 'workspace.terminal.closeConfirm': 'Cerrar y finalizar', 'workspace.localSqlFileTree.revealFailed': 'No se pudo mostrar el archivo', 'workspace.databaseAccount.title': 'Usuarios', 'workspace.databaseAccount.open': 'Abrir usuarios', @@ -268,7 +286,8 @@ export default { 'workspace.databaseAccount.selectPrivileges': 'Seleccionar privilegios', 'workspace.databaseAccount.grantOption': 'Permitir concesiones adicionales (WITH GRANT OPTION)', 'workspace.databaseAccount.previewSql': 'Vista previa de SQL', - 'workspace.databaseAccount.previewEmpty': 'Complete los detalles de los privilegios para generar el SQL automáticamente', + 'workspace.databaseAccount.previewEmpty': + 'Complete los detalles de los privilegios para generar el SQL automáticamente', 'workspace.databaseAccount.confirmChange': 'Confirmar cambios', 'workspace.databaseAccount.executeSql': 'Ejecutar SQL', 'workspace.databaseAccount.executeSuccess': 'Ejecución correcta', diff --git a/chat2db-community-client/src/i18n/ja-JP/common.ts b/chat2db-community-client/src/i18n/ja-JP/common.ts index 54780809d6..b66d8a6f85 100644 --- a/chat2db-community-client/src/i18n/ja-JP/common.ts +++ b/chat2db-community-client/src/i18n/ja-JP/common.ts @@ -76,6 +76,7 @@ export default { 'common.button.copyName': '名前をコピー', 'common.button.copySuccessfully': 'コピー成功', 'common.button.createConsole': '新しいコンソールを作成', + 'common.button.openInNewConsole': '新しいコンソールで開く', 'common.button.exportWord': 'Wordにエクスポート', 'common.button.exportExcel': 'Excelにエクスポート', 'common.button.exportHtml': 'Htmlにエクスポート', @@ -228,9 +229,13 @@ export default { 'common.button.clearConsole': '出力をクリア', 'common.button.followConsole': '最新のログを追従', 'common.button.keepHistoryOutput': '出力履歴を保持', + 'common.button.keepHistoryResult': '既存の結果を保持', 'common.text.order': '並び順', 'common.text.oldestFirst': '古い順', 'common.text.newestFirst': '新しい順', + 'common.text.resultSettings': '結果設定', + 'common.text.moreTabs': 'その他のタブ', + 'common.text.cancelRequestFailed': 'リクエストをキャンセルできませんでした。もう一度お試しください。', 'common.text.rowsReturned': '{1} 行を取得', 'common.text.executeDuration': '実行 {1} ms', 'common.text.fetchDuration': '取得 {1} ms', diff --git a/chat2db-community-client/src/i18n/ja-JP/connection.ts b/chat2db-community-client/src/i18n/ja-JP/connection.ts index 4b3bc8f268..9c52ce783d 100644 --- a/chat2db-community-client/src/i18n/ja-JP/connection.ts +++ b/chat2db-community-client/src/i18n/ja-JP/connection.ts @@ -44,5 +44,9 @@ export default { 'connection.title.importDBeaver': 'DBeaverリンクをインポート', 'connection.import.success': '{1} つの接続を正常にインポートしました', 'connection.import.unable.password.tips': '接続パスワードをインポートできません。インポートが完了したら、接続パスワードを再入力してください', + 'connection.export.exporting': '接続ファイルを準備しています...', + 'connection.export.success': '接続をエクスポートしました', + 'connection.export.cancelled': 'エクスポートをキャンセルしました', + 'connection.export.failed': '接続のエクスポートに失敗しました。もう一度お試しください', 'workspace.menu.exportConnections': '接続をエクスポート', }; diff --git a/chat2db-community-client/src/i18n/ja-JP/monaco.ts b/chat2db-community-client/src/i18n/ja-JP/monaco.ts index 73eb4a5723..db45079ac6 100644 --- a/chat2db-community-client/src/i18n/ja-JP/monaco.ts +++ b/chat2db-community-client/src/i18n/ja-JP/monaco.ts @@ -33,6 +33,8 @@ export default { 'monaco.text.cut': '切り取り', 'monaco.text.caseConvert': '大文字小文字変換', 'monaco.text.formatSQL': 'SQLをフォーマット', + 'monaco.text.toggleLineComment': '行 SQL コメントの切り替え', + 'monaco.text.toggleBlockComment': 'ブロック SQL コメントの切り替え', 'monaco.text.saveSQL': '保存', 'monaco.text.saveFile': '保存', 'monaco.text.saveFileToDesktop': 'ファイルを保存', diff --git a/chat2db-community-client/src/i18n/ja-JP/setting.ts b/chat2db-community-client/src/i18n/ja-JP/setting.ts index 75696f5ec2..3b1d24ec5e 100644 --- a/chat2db-community-client/src/i18n/ja-JP/setting.ts +++ b/chat2db-community-client/src/i18n/ja-JP/setting.ts @@ -16,6 +16,23 @@ export default { 'setting.nav.editSetting': 'エディタ設定', 'setting.nav.apiKeys': 'APIキー', 'setting.nav.editSettingDescribe': 'SQL エディタ設定', + 'setting.nav.terminal': 'ターミナル設定', + 'setting.nav.terminalDescribe': 'ターミナルの表示位置、終了確認、既定のシェル、配色を設定', + 'setting.terminal.openPosition': 'ターミナルの表示位置', + 'setting.terminal.openPositionDescribe': '新しいターミナルタブをワークスペースのどこに表示するか選択します。', + 'setting.terminal.openPositionTab': 'メインタブ', + 'setting.terminal.openPositionBottom': '下部パネル', + 'setting.terminal.openPositionRight': '右パネル', + 'setting.terminal.openPositionApplyHint': '新しいターミナルに適用され、開いているターミナルの位置は変わりません。', + 'setting.terminal.confirmBeforeClose': '終了前に確認', + 'setting.terminal.confirmBeforeCloseDescribe': '実行中のターミナルを閉じる前に確認ダイアログを表示します。', + 'setting.terminal.shell': '既定のシェル', + 'setting.terminal.shellDescribe': '新しいターミナルで使用する環境を選択します。インストール済みのシェルのみ表示します。', + 'setting.terminal.shellApplyHint': '新しいターミナルから適用され、複製時は元のシェルを引き継ぎます。', + 'setting.terminal.theme': 'ターミナルテーマ', + 'setting.terminal.themeDescribe': '背景、文字、ANSI コマンドの色を選択します。', + 'setting.terminal.themeApplyHint': '開いているすべてのターミナルにすぐ適用されます。', + 'setting.terminal.capabilitiesFailed': 'シェル一覧を取得できません。デスクトップアプリを再起動してください。', 'setting.nav.mcp': 'MCPサービス', 'setting.nav.mcpDescribe': 'ローカル MCP 起動設定', 'setting.nav.networkProxy': 'ネットワークプロキシ', @@ -159,6 +176,7 @@ export default { 'setting.shortcut.table.resetAll': 'すべてリセット', 'setting.shortcut.group.global': 'グローバル', 'setting.shortcut.group.workspace': 'ワークスペース', + 'setting.shortcut.group.databaseTree': 'データソースツリー', 'setting.shortcut.group.localSqlFileTree': 'テキストファイル', 'setting.shortcut.group.sqlEditor': 'SQL エディター', 'setting.shortcut.group.resultSet': '結果セット', @@ -275,6 +293,10 @@ export default { 'setting.shortcut.arouseAIAssistant': 'AI アシスタントを起動', 'setting.shortcut.newAIChat': '新しい AI チャット', 'setting.shortcut.workspaceTreeSearch': 'データソースツリーを検索', + 'setting.shortcut.databaseTreeOpenTable': 'テーブルを開く', + 'setting.shortcut.databaseTreeEditTable': 'テーブルを編集', + 'setting.shortcut.databaseTreeCreateTable': '新しいテーブル', + 'setting.shortcut.databaseTreeRefresh': '更新', 'setting.shortcut.localSqlFileTreeOpenFolder': 'フォルダーを開く', 'setting.shortcut.localSqlFileTreeAddFolder': 'フォルダーを追加', 'setting.shortcut.localSqlFileTreeOpen': 'テキストファイルを開く', @@ -294,6 +316,8 @@ export default { 'setting.shortcut.sqlCut': 'SQL エディター 切り取り', 'setting.shortcut.sqlCaseConvert': 'SQL 大文字小文字変換', 'setting.shortcut.sqlFormat': 'SQL をフォーマット', + 'setting.shortcut.sqlToggleLineComment': '行 SQL コメントの切り替え', + 'setting.shortcut.sqlToggleBlockComment': 'ブロック SQL コメントの切り替え', 'setting.shortcut.sqlSave': 'SQL を保存', 'setting.shortcut.sqlSaveToDesktop': 'SQL をローカルに保存', 'setting.shortcut.sqlExecuteCurrent': '現在の SQL を実行', diff --git a/chat2db-community-client/src/i18n/ja-JP/workspace.ts b/chat2db-community-client/src/i18n/ja-JP/workspace.ts index 55d8bab8a1..d787cc6e50 100644 --- a/chat2db-community-client/src/i18n/ja-JP/workspace.ts +++ b/chat2db-community-client/src/i18n/ja-JP/workspace.ts @@ -5,6 +5,9 @@ export default { 'workspace.title.savedConsole': '保存されたコンソール', 'workspace.savedConsole.unknownDataSource': '未指定のデータソース', 'workspace.savedConsole.unknownDatabase': '未指定のデータベース', + 'workspace.savedConsole.initialSaveTitle': 'SQL を保存', + 'workspace.savedConsole.namePlaceholder': '保存する SQL の名前を入力してください', + 'workspace.savedConsole.untitled': '無題の SQL', 'workspace.title.ai': 'AI', 'workspace.menu.syncDataBase': 'データベースを同期', 'workspace.menu.ViewDDL': 'DDLを見る', @@ -242,6 +245,21 @@ export default { 'workspace.localSqlFileTree.copySuccess': 'コピーしました', 'workspace.localSqlFileTree.copyFailed': 'コピーに失敗しました', 'workspace.localSqlFileTree.openTerminalFailed': 'ターミナルを開けませんでした', + 'workspace.filePreview.mermaidFailed': 'Mermaid 図をレンダリングできませんでした。構文を確認してください', + 'workspace.filePreview.source': 'ソース', + 'workspace.filePreview.preview': 'プレビュー', + 'workspace.filePreview.split': 'ソースとプレビュー', + 'workspace.filePreview.imageFailed': '画像を読み込めませんでした', + 'workspace.filePreview.pdfUnsupported': 'このデスクトップ環境では PDF を表示できません', + 'workspace.filePreview.unsupported': 'このファイルはプレビューできません', + 'workspace.terminal.title': 'ターミナル', + 'workspace.terminal.stop': '終了', + 'workspace.terminal.exited': 'プロセス終了コード', + 'workspace.terminal.closeTitle': 'ターミナルを閉じますか?', + 'workspace.terminal.closeContent': 'このタブを閉じると、現在のターミナルプロセスが終了します。', + 'workspace.terminal.closeBusyContent': + 'このターミナルでプログラムが実行中です。タブを閉じると、プログラムとターミナルプロセスが終了します。', + 'workspace.terminal.closeConfirm': '閉じて終了', 'workspace.localSqlFileTree.revealFailed': 'ファイルを表示できませんでした', 'workspace.databaseAccount.title': 'ユーザー', 'workspace.databaseAccount.open': 'ユーザーを開く', @@ -278,7 +296,8 @@ export default { 'workspace.databaseAccount.operation': '操作', 'workspace.databaseAccount.grantPrivilege': '権限を付与', 'workspace.databaseAccount.revokePrivilege': '権限を取り消し', - 'workspace.databaseAccount.lockUnsupported': 'この MySQL バージョンまたは現在の権限ではアカウントロックを利用できません', + 'workspace.databaseAccount.lockUnsupported': + 'この MySQL バージョンまたは現在の権限ではアカウントロックを利用できません', 'workspace.routine.button.invoke': 'ルーチンを呼び出す', 'workspace.routine.button.apply': '変更を適用', 'workspace.routine.button.refresh': 'DDL を再読み込み', diff --git a/chat2db-community-client/src/i18n/ko-KR/common.ts b/chat2db-community-client/src/i18n/ko-KR/common.ts index d94cc5bfc0..fce91c534f 100644 --- a/chat2db-community-client/src/i18n/ko-KR/common.ts +++ b/chat2db-community-client/src/i18n/ko-KR/common.ts @@ -76,6 +76,7 @@ export default { 'common.button.copyName': '이름 복사', 'common.button.copySuccessfully': '복사되었습니다', 'common.button.createConsole': '콘솔 만들기', + 'common.button.openInNewConsole': '새 콘솔에서 열기', 'common.button.exportWord': 'Word로 내보내기', 'common.button.exportExcel': 'Excel로 내보내기', 'common.button.exportHtml': 'HTML로 내보내기', @@ -265,9 +266,13 @@ export default { 'common.button.clearConsole': '출력 지우기', 'common.button.followConsole': '최신 로그 따라가기', 'common.button.keepHistoryOutput': '출력 기록 유지', + 'common.button.keepHistoryResult': '기존 결과 유지', 'common.text.order': '정렬', 'common.text.oldestFirst': '오래된 순', 'common.text.newestFirst': '최신 순', + 'common.text.resultSettings': '결과 설정', + 'common.text.moreTabs': '더 많은 탭', + 'common.text.cancelRequestFailed': '요청을 취소하지 못했습니다. 다시 시도하세요.', 'common.text.rowsReturned': '{1}개 행 반환', 'common.text.executeDuration': '실행 {1} ms', 'common.text.fetchDuration': '읽기 {1} ms', diff --git a/chat2db-community-client/src/i18n/ko-KR/connection.ts b/chat2db-community-client/src/i18n/ko-KR/connection.ts index 074fa51c9a..1ca85a3a4b 100644 --- a/chat2db-community-client/src/i18n/ko-KR/connection.ts +++ b/chat2db-community-client/src/i18n/ko-KR/connection.ts @@ -46,5 +46,9 @@ export default { 'connection.import.success': '연결 {1}개를 가져왔습니다', 'connection.import.unable.password.tips': '연결 비밀번호는 가져올 수 없습니다. 가져오기가 완료된 후 연결 비밀번호를 다시 입력해 주세요', + 'connection.export.exporting': '연결 파일을 준비하는 중...', + 'connection.export.success': '연결을 내보냈습니다', + 'connection.export.cancelled': '내보내기를 취소했습니다', + 'connection.export.failed': '연결을 내보내지 못했습니다. 다시 시도해 주세요', 'workspace.menu.exportConnections': '연결 내보내기', }; diff --git a/chat2db-community-client/src/i18n/ko-KR/monaco.ts b/chat2db-community-client/src/i18n/ko-KR/monaco.ts index 525e2ffde0..f8697d3109 100644 --- a/chat2db-community-client/src/i18n/ko-KR/monaco.ts +++ b/chat2db-community-client/src/i18n/ko-KR/monaco.ts @@ -33,6 +33,8 @@ export default { 'monaco.text.cut': '잘라내기', 'monaco.text.caseConvert': '대소문자 변환', 'monaco.text.formatSQL': 'SQL 서식 지정', + 'monaco.text.toggleLineComment': 'SQL 줄 주석 토글', + 'monaco.text.toggleBlockComment': 'SQL 블록 주석 토글', 'monaco.text.saveSQL': '저장', 'monaco.text.saveFile': '저장', 'monaco.text.saveFileToDesktop': '파일로 저장', diff --git a/chat2db-community-client/src/i18n/ko-KR/setting.ts b/chat2db-community-client/src/i18n/ko-KR/setting.ts index c34f62a888..d9012d0c0f 100644 --- a/chat2db-community-client/src/i18n/ko-KR/setting.ts +++ b/chat2db-community-client/src/i18n/ko-KR/setting.ts @@ -16,6 +16,23 @@ export default { 'setting.nav.editSetting': '편집기 설정', 'setting.nav.apiKeys': 'API 키', 'setting.nav.editSettingDescribe': 'SQL 편집기 설정', + 'setting.nav.terminal': '터미널 설정', + 'setting.nav.terminalDescribe': '터미널 위치, 닫기 확인, 기본 셸 및 색상 구성', + 'setting.terminal.openPosition': '터미널 열기 위치', + 'setting.terminal.openPositionDescribe': '새 터미널 탭이 작업 공간에서 열릴 위치를 선택합니다.', + 'setting.terminal.openPositionTab': '기본 탭', + 'setting.terminal.openPositionBottom': '하단 패널', + 'setting.terminal.openPositionRight': '오른쪽 패널', + 'setting.terminal.openPositionApplyHint': '새 터미널에 적용되며 열린 터미널의 위치는 유지됩니다.', + 'setting.terminal.confirmBeforeClose': '닫기 전에 확인', + 'setting.terminal.confirmBeforeCloseDescribe': '실행 중인 터미널을 닫기 전에 확인 창을 표시합니다.', + 'setting.terminal.shell': '기본 셸', + 'setting.terminal.shellDescribe': '새 터미널에서 사용할 환경을 선택합니다. 설치된 셸만 표시됩니다.', + 'setting.terminal.shellApplyHint': '새 터미널부터 적용되며 복제한 터미널은 원본 셸을 유지합니다.', + 'setting.terminal.theme': '터미널 테마', + 'setting.terminal.themeDescribe': '배경, 텍스트 및 ANSI 명령 색상을 선택합니다.', + 'setting.terminal.themeApplyHint': '열려 있는 모든 터미널에 즉시 적용됩니다.', + 'setting.terminal.capabilitiesFailed': '설치된 셸을 읽을 수 없습니다. 데스크톱 앱을 다시 시작하세요.', 'setting.nav.mcp': 'MCP 서비스', 'setting.nav.mcpDescribe': '로컬 MCP 시작 관리', 'setting.nav.networkProxy': '네트워크 프록시', @@ -157,6 +174,7 @@ export default { 'setting.shortcut.table.resetAll': '모두 초기화', 'setting.shortcut.group.global': '전역', 'setting.shortcut.group.workspace': '워크스페이스', + 'setting.shortcut.group.databaseTree': '데이터 소스 트리', 'setting.shortcut.group.localSqlFileTree': '텍스트 파일', 'setting.shortcut.group.sqlEditor': 'SQL 편집기', 'setting.shortcut.group.resultSet': '결과 집합', @@ -170,6 +188,10 @@ export default { 'setting.shortcut.arouseAIAssistant': 'AI 도우미 호출', 'setting.shortcut.newAIChat': '새 AI 채팅', 'setting.shortcut.workspaceTreeSearch': '데이터 소스 트리 검색', + 'setting.shortcut.databaseTreeOpenTable': '테이블 열기', + 'setting.shortcut.databaseTreeEditTable': '테이블 편집', + 'setting.shortcut.databaseTreeCreateTable': '새 테이블', + 'setting.shortcut.databaseTreeRefresh': '새로 고침', 'setting.shortcut.localSqlFileTreeOpenFolder': '폴더 열기', 'setting.shortcut.localSqlFileTreeAddFolder': '폴더 추가', 'setting.shortcut.localSqlFileTreeOpen': '텍스트 파일 열기', @@ -189,6 +211,8 @@ export default { 'setting.shortcut.sqlCut': 'SQL 편집기 잘라내기', 'setting.shortcut.sqlCaseConvert': 'SQL 대소문자 변환', 'setting.shortcut.sqlFormat': 'SQL 서식 지정', + 'setting.shortcut.sqlToggleLineComment': 'SQL 줄 주석 토글', + 'setting.shortcut.sqlToggleBlockComment': 'SQL 블록 주석 토글', 'setting.shortcut.sqlSave': 'SQL 저장', 'setting.shortcut.sqlSaveToDesktop': 'SQL을 로컬에 저장', 'setting.shortcut.sqlExecuteCurrent': '현재 SQL 실행', diff --git a/chat2db-community-client/src/i18n/ko-KR/workspace.ts b/chat2db-community-client/src/i18n/ko-KR/workspace.ts index 222366de16..7ec64b0b79 100644 --- a/chat2db-community-client/src/i18n/ko-KR/workspace.ts +++ b/chat2db-community-client/src/i18n/ko-KR/workspace.ts @@ -5,6 +5,9 @@ export default { 'workspace.title.savedConsole': '저장된 콘솔', 'workspace.savedConsole.unknownDataSource': '지정되지 않은 데이터 소스', 'workspace.savedConsole.unknownDatabase': '지정되지 않은 데이터베이스', + 'workspace.savedConsole.initialSaveTitle': 'SQL 저장', + 'workspace.savedConsole.namePlaceholder': '저장할 SQL의 이름을 입력하세요', + 'workspace.savedConsole.untitled': '제목 없는 SQL', 'workspace.title.ai': 'AI', 'workspace.menu.syncDataBase': '데이터베이스 동기화', 'workspace.menu.ViewDDL': 'DDL 보기', @@ -241,6 +244,21 @@ export default { 'workspace.localSqlFileTree.copySuccess': '복사됨', 'workspace.localSqlFileTree.copyFailed': '복사하지 못했습니다', 'workspace.localSqlFileTree.openTerminalFailed': '터미널을 열지 못했습니다', + 'workspace.filePreview.mermaidFailed': 'Mermaid 다이어그램을 렌더링하지 못했습니다. 구문을 확인하세요', + 'workspace.filePreview.source': '소스', + 'workspace.filePreview.preview': '미리 보기', + 'workspace.filePreview.split': '소스와 미리 보기', + 'workspace.filePreview.imageFailed': '이미지를 불러오지 못했습니다', + 'workspace.filePreview.pdfUnsupported': '현재 데스크톱 환경에서 PDF를 표시할 수 없습니다', + 'workspace.filePreview.unsupported': '이 파일은 미리 볼 수 없습니다', + 'workspace.terminal.title': '터미널', + 'workspace.terminal.stop': '종료', + 'workspace.terminal.exited': '프로세스 종료 코드', + 'workspace.terminal.closeTitle': '터미널을 닫으시겠습니까?', + 'workspace.terminal.closeContent': '이 탭을 닫으면 현재 터미널 프로세스가 종료됩니다.', + 'workspace.terminal.closeBusyContent': + '이 터미널에서 프로그램이 실행 중입니다. 탭을 닫으면 프로그램과 터미널 프로세스가 종료됩니다.', + 'workspace.terminal.closeConfirm': '닫고 종료', 'workspace.localSqlFileTree.revealFailed': '파일 위치를 표시하지 못했습니다', 'workspace.databaseAccount.title': '사용자', 'workspace.databaseAccount.open': '사용자 열기', diff --git a/chat2db-community-client/src/i18n/zh-CN/common.ts b/chat2db-community-client/src/i18n/zh-CN/common.ts index b0c5b49249..8f62c669c1 100644 --- a/chat2db-community-client/src/i18n/zh-CN/common.ts +++ b/chat2db-community-client/src/i18n/zh-CN/common.ts @@ -76,6 +76,7 @@ export default { 'common.button.copyName': '复制名称', 'common.button.copySuccessfully': '复制成功', 'common.button.createConsole': '新建控制台', + 'common.button.openInNewConsole': '在新控制台中打开', 'common.button.exportWord': '导出到Word', 'common.button.exportExcel': '导出到Excel', 'common.button.exportHtml': '导出到Html', @@ -226,9 +227,13 @@ export default { 'common.button.clearConsole': '清空输出', 'common.button.followConsole': '跟随最新日志', 'common.button.keepHistoryOutput': '保留历史输出', + 'common.button.keepHistoryResult': '保留已有结果', 'common.text.order': '排序规则', 'common.text.oldestFirst': '最早优先', 'common.text.newestFirst': '最新优先', + 'common.text.resultSettings': '结果设置', + 'common.text.moreTabs': '更多标签页', + 'common.text.cancelRequestFailed': '取消请求失败,请重试', 'common.text.rowsReturned': '返回 {1} 行', 'common.text.executeDuration': '执行 {1} ms', 'common.text.fetchDuration': '读取 {1} ms', diff --git a/chat2db-community-client/src/i18n/zh-CN/connection.ts b/chat2db-community-client/src/i18n/zh-CN/connection.ts index 595617ae18..b631b431f4 100644 --- a/chat2db-community-client/src/i18n/zh-CN/connection.ts +++ b/chat2db-community-client/src/i18n/zh-CN/connection.ts @@ -44,5 +44,9 @@ export default { 'connection.title.importDBeaver': '导入DBeaver连接', 'connection.import.success': '成功导入{1}条连接', 'connection.import.unable.password.tips': '我们无法导入连接密码,导入完成后请重新输入连接密码', + 'connection.export.exporting': '正在准备连接文件...', + 'connection.export.success': '连接导出成功', + 'connection.export.cancelled': '已取消导出', + 'connection.export.failed': '连接导出失败,请重试', 'workspace.menu.exportConnections': '导出连接', }; diff --git a/chat2db-community-client/src/i18n/zh-CN/monaco.ts b/chat2db-community-client/src/i18n/zh-CN/monaco.ts index 280b039a57..a2d226b744 100644 --- a/chat2db-community-client/src/i18n/zh-CN/monaco.ts +++ b/chat2db-community-client/src/i18n/zh-CN/monaco.ts @@ -32,6 +32,8 @@ export default { 'monaco.text.cut': '剪切', 'monaco.text.caseConvert': '大小写转换', 'monaco.text.formatSQL': '格式化SQL', + 'monaco.text.toggleLineComment': '切换行注释', + 'monaco.text.toggleBlockComment': '切换块注释', 'monaco.text.saveSQL': '保存', 'monaco.text.saveFile': '保存', 'monaco.text.saveFileToDesktop': '另存为', diff --git a/chat2db-community-client/src/i18n/zh-CN/setting.ts b/chat2db-community-client/src/i18n/zh-CN/setting.ts index b08117d813..15ba8a6528 100644 --- a/chat2db-community-client/src/i18n/zh-CN/setting.ts +++ b/chat2db-community-client/src/i18n/zh-CN/setting.ts @@ -19,6 +19,23 @@ export default { 'setting.nav.createOrgJoinOrg': '创建或加入团队', 'setting.nav.switchPersonal': '回到个人版', 'setting.nav.editSettingDescribe': 'SQL编辑器设置', + 'setting.nav.terminal': '终端设置', + 'setting.nav.terminalDescribe': '配置终端打开位置、关闭行为、默认 Shell 和配色', + 'setting.terminal.openPosition': '终端打开位置', + 'setting.terminal.openPositionDescribe': '选择新建终端标签在工作区中的打开位置。', + 'setting.terminal.openPositionTab': '主标签页', + 'setting.terminal.openPositionBottom': '底部面板', + 'setting.terminal.openPositionRight': '右侧面板', + 'setting.terminal.openPositionApplyHint': '仅对之后新建的终端生效,已打开终端保持当前位置。', + 'setting.terminal.confirmBeforeClose': '关闭终端前确认', + 'setting.terminal.confirmBeforeCloseDescribe': '关闭仍在运行的终端前显示二次确认。', + 'setting.terminal.shell': '默认 Shell', + 'setting.terminal.shellDescribe': '选择新建终端使用的命令行环境,只显示当前系统已安装的 Shell。', + 'setting.terminal.shellApplyHint': 'Shell 设置会在下次新建终端时生效;复制终端会沿用源终端的 Shell。', + 'setting.terminal.theme': '终端主题', + 'setting.terminal.themeDescribe': '选择终端背景、文本和 ANSI 命令颜色。', + 'setting.terminal.themeApplyHint': '主题会立即应用到所有已打开的终端。', + 'setting.terminal.capabilitiesFailed': '无法读取当前系统的 Shell 列表,请重启桌面应用后重试。', 'setting.nav.mcp': 'MCP服务', 'setting.nav.mcpDescribe': '管理本地 MCP 启动', 'setting.nav.networkProxy': '网络代理', @@ -145,6 +162,7 @@ export default { 'setting.shortcut.table.resetAll': '重置全部', 'setting.shortcut.group.global': '全局', 'setting.shortcut.group.workspace': '工作区', + 'setting.shortcut.group.databaseTree': '数据源树', 'setting.shortcut.group.localSqlFileTree': '文本文件', 'setting.shortcut.group.sqlEditor': 'SQL 编辑器', 'setting.shortcut.group.resultSet': '结果集', @@ -165,11 +183,17 @@ export default { 'setting.shortcut.zoomOut': '缩小', 'setting.shortcut.zoomReset': '重置', 'setting.shortcut.workspaceTreeSearch': '搜索数据源树', + 'setting.shortcut.databaseTreeOpenTable': '打开表', + 'setting.shortcut.databaseTreeEditTable': '编辑表', + 'setting.shortcut.databaseTreeCreateTable': '新建表', + 'setting.shortcut.databaseTreeRefresh': '刷新', 'setting.shortcut.sqlCopy': 'SQL 编辑器复制', 'setting.shortcut.sqlPaste': 'SQL 编辑器粘贴', 'setting.shortcut.sqlCut': 'SQL 编辑器剪切', 'setting.shortcut.sqlCaseConvert': 'SQL 大小写转换', 'setting.shortcut.sqlFormat': '格式化 SQL', + 'setting.shortcut.sqlToggleLineComment': '切换行注释', + 'setting.shortcut.sqlToggleBlockComment': '切换块注释', 'setting.shortcut.sqlSave': '保存 SQL', 'setting.shortcut.sqlSaveToDesktop': '保存 SQL 到本地', 'setting.shortcut.sqlExecuteCurrent': '执行当前 SQL', diff --git a/chat2db-community-client/src/i18n/zh-CN/workspace.ts b/chat2db-community-client/src/i18n/zh-CN/workspace.ts index 36403b1a97..eec5da0b61 100644 --- a/chat2db-community-client/src/i18n/zh-CN/workspace.ts +++ b/chat2db-community-client/src/i18n/zh-CN/workspace.ts @@ -5,6 +5,9 @@ export default { 'workspace.title.savedConsole': '保存记录', 'workspace.savedConsole.unknownDataSource': '未指定数据源', 'workspace.savedConsole.unknownDatabase': '未指定数据库', + 'workspace.savedConsole.initialSaveTitle': '保存 SQL', + 'workspace.savedConsole.namePlaceholder': '请输入保存记录名称', + 'workspace.savedConsole.untitled': '未命名 SQL', 'workspace.title.ai': 'AI', 'workspace.menu.syncDataBase': '同步数据库', 'workspace.menu.ViewDDL': '查看DDL', @@ -236,6 +239,20 @@ export default { 'workspace.localSqlFileTree.copySuccess': '已复制', 'workspace.localSqlFileTree.copyFailed': '复制失败', 'workspace.localSqlFileTree.openTerminalFailed': '打开终端失败', + 'workspace.filePreview.mermaidFailed': 'Mermaid 图表渲染失败,请检查语法', + 'workspace.filePreview.source': '源码', + 'workspace.filePreview.preview': '预览', + 'workspace.filePreview.split': '源码和预览', + 'workspace.filePreview.imageFailed': '图片加载失败', + 'workspace.filePreview.pdfUnsupported': '当前桌面运行环境无法显示 PDF', + 'workspace.filePreview.unsupported': '暂不支持预览此文件', + 'workspace.terminal.title': '终端', + 'workspace.terminal.stop': '结束', + 'workspace.terminal.exited': '进程已退出,退出码', + 'workspace.terminal.closeTitle': '关闭终端?', + 'workspace.terminal.closeContent': '关闭此 Tab 将结束当前终端进程。', + 'workspace.terminal.closeBusyContent': '检测到终端中有程序正在运行,关闭此 Tab 将结束该程序和终端进程。', + 'workspace.terminal.closeConfirm': '关闭并结束进程', 'workspace.localSqlFileTree.revealFailed': '定位文件失败', 'workspace.databaseAccount.title': '用户', 'workspace.databaseAccount.open': '打开用户', diff --git a/chat2db-community-client/src/jcef/eventBus.ts b/chat2db-community-client/src/jcef/eventBus.ts index 576f3eea20..6bdc803a21 100644 --- a/chat2db-community-client/src/jcef/eventBus.ts +++ b/chat2db-community-client/src/jcef/eventBus.ts @@ -9,6 +9,8 @@ export enum JavaPushActionType { IS_WINDOW_MAXIMIZED = 'is_window_maximized', // Whether the window is maximized OSS_LOGIN = 'oss_login', // OSS login requires jumping to the homepage SQL_EXECUTION_EVENT = 'sql_execution_event', // SQL execution events + TERMINAL_OUTPUT = 'terminal_output', // Integrated terminal output + TERMINAL_EXIT = 'terminal_exit', // Integrated terminal exit } export const JcefEventBus = { @@ -19,8 +21,15 @@ export const JcefEventBus = { listeners[eventType].push(callback); }, - off(eventType) { - delete listeners[eventType]; + off(eventType, callback?) { + if (!callback) { + delete listeners[eventType]; + return; + } + listeners[eventType] = listeners[eventType]?.filter((listener) => listener !== callback); + if (!listeners[eventType]?.length) { + delete listeners[eventType]; + } }, publish(eventType, data) { diff --git a/chat2db-community-client/src/jcef/index.tsx b/chat2db-community-client/src/jcef/index.tsx index 6622f3b8a7..dce421758a 100644 --- a/chat2db-community-client/src/jcef/index.tsx +++ b/chat2db-community-client/src/jcef/index.tsx @@ -33,6 +33,13 @@ const jcefApi = { getSqlDirectoryChildren: (params: { rootToken: string; relativePath: string }) => { return createJcefApi('get-sql-directory-children', params); }, + // Read a binary preview file from an opened directory + readSqlDirectoryPreview: (params: { rootToken: string; relativePath: string }) => { + return createJcefApi<{ url: string; mimeType: string; size: number; etag: string }>( + 'read-sql-directory-preview', + params, + ); + }, // Create a new SQL file directory subnode createSqlDirectoryChild: (params: { rootToken: string; @@ -58,6 +65,63 @@ const jcefApi = { openSqlDirectoryTerminal: (params: { rootToken: string; relativePath: string }) => { return createJcefApi('open-sql-directory-terminal', params); }, + createSqlDirectoryTerminal: (params: { + rootToken: string; + relativePath: string; + columns: number; + rows: number; + shellId?: string; + }) => { + return createJcefApi<{ sessionId: string; cwd: string; shell: string; shellId: string }>( + 'create-sql-directory-terminal', + params, + ); + }, + createTerminal: (params: { columns: number; rows: number; shellId?: string }) => { + return createJcefApi<{ sessionId: string; cwd: string; shell: string; shellId: string }>( + 'create-terminal', + params, + ); + }, + duplicateTerminal: (params: { sessionId: string; columns: number; rows: number }) => { + return createJcefApi<{ sessionId: string; cwd: string; shell: string; shellId: string }>( + 'duplicate-terminal', + params, + ); + }, + getTerminalCapabilities: () => { + return createJcefApi<{ + os: 'mac' | 'windows' | 'linux'; + shells: Array<{ id: string; label: string; available: boolean }>; + }>('get-terminal-capabilities'); + }, + writeTerminal: (params: { sessionId: string; data: string }) => { + return createJcefApi('write-terminal', params); + }, + attachTerminal: (params: { sessionId: string; consumerId: string }) => { + return createJcefApi('attach-terminal', params); + }, + detachTerminal: (params: { sessionId: string; consumerId: string }) => { + return createJcefApi('detach-terminal', params); + }, + acknowledgeTerminalOutput: (params: { sessionId: string; sequence: number }) => { + return createJcefApi('ack-terminal-output', params); + }, + resizeTerminal: (params: { sessionId: string; columns: number; rows: number }) => { + return createJcefApi('resize-terminal', params); + }, + getTerminalStatus: (params: { sessionId: string }) => { + return createJcefApi<{ alive: boolean; busy: boolean }>('get-terminal-status', params); + }, + getTerminalStatuses: (params: { sessionIds: string[] }) => { + return createJcefApi>('get-terminal-statuses', params); + }, + killTerminal: (params: { sessionId: string }) => { + return createJcefApi('kill-terminal', params); + }, + killTerminals: (params: { sessionIds: string[] }) => { + return createJcefApi('kill-terminals', params); + }, // Select file selectFile: (params: { fileTypeList: string[]; fileSize?: number; multiple?: boolean }) => { return createJcefApi('select-file', params); diff --git a/chat2db-community-client/src/pages/main/CommunityMainPage.tsx b/chat2db-community-client/src/pages/main/CommunityMainPage.tsx index e2e3f5505a..80660bd016 100644 --- a/chat2db-community-client/src/pages/main/CommunityMainPage.tsx +++ b/chat2db-community-client/src/pages/main/CommunityMainPage.tsx @@ -1,6 +1,6 @@ import { Confetti, IconButton, IconfontSvg } from '@chat2db/ui'; import { Tooltip, type InputRef } from 'antd'; -import { createLucideIcon, Layers, MessagesSquare } from 'lucide-react'; +import { Layers, LayoutDashboard, MessageSquarePlus } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import i18n from '@/i18n'; @@ -34,16 +34,6 @@ import { useWorkspaceStore } from '@/store/workspace'; import { isDesktop, isHashHistoryEnv } from '@/utils/env'; import { checkIsSharePage } from '@/utils/url'; -// Backport the official Lucide icon; the direct 0.356 dependency predates this export. -const ChartNoAxesCombined = createLucideIcon('ChartNoAxesCombined', [ - ['path', { d: 'M12 16v5', key: 'zza2cw' }], - ['path', { d: 'M16 14v7', key: '1g90b9' }], - ['path', { d: 'M20 10v11', key: '1iqoj0' }], - ['path', { d: 'm22 3-8.646 8.646a.5.5 0 0 1-.708 0L9.354 8.354a.5.5 0 0 0-.707 0L2 15', key: '1fw8x9' }], - ['path', { d: 'M4 18v3', key: '1yp0dc' }], - ['path', { d: 'M8 14v7', key: 'n3cwzv' }], -]); - function CommunityMainPage() { const [navConfig, setNavConfig] = useState([]); @@ -51,7 +41,7 @@ function CommunityMainPage() { () => [ { key: 'stream', - icon: MessagesSquare, + icon: MessageSquarePlus, isLoad: false, component: , name: i18n('stream.nav.title'), @@ -65,7 +55,7 @@ function CommunityMainPage() { }, { key: 'dashboard', - icon: ChartNoAxesCombined, + icon: LayoutDashboard, isLoad: false, component: , name: i18n('dashboard.title'), diff --git a/chat2db-community-client/src/pages/main/organization/components/MemberManagement/index.tsx b/chat2db-community-client/src/pages/main/organization/components/MemberManagement/index.tsx index 790217b5ef..6e575f3ad3 100644 --- a/chat2db-community-client/src/pages/main/organization/components/MemberManagement/index.tsx +++ b/chat2db-community-client/src/pages/main/organization/components/MemberManagement/index.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { beginLatestRequest, invalidateLatestRequest, isLatestRequest } from '@/utils/latestRequest'; import orgService from '@/service/enterprise/organization'; import { PlusOutlined, CopyOutlined, LinkOutlined } from '@ant-design/icons'; import { IOrganizationUserVO, OrgUserRoleCode, OrganizationStatusType } from '@/typings/enterprise/organization'; @@ -49,8 +50,12 @@ function UserList() { seats: number; }>(); + const requestGenerationRef = useRef(0); + useEffect(() => { + const requestGeneration = beginLatestRequest(requestGenerationRef); orgService.queryOrgDetail().then((res) => { + if (!isLatestRequest(requestGenerationRef, requestGeneration)) return; if (res) { setOrgMemberCount({ currentMemberCount: res.currentMemberCount, @@ -58,6 +63,9 @@ function UserList() { }); } }); + return () => { + invalidateLatestRequest(requestGenerationRef); + }; }, []); const findHighRole = (roleCodes: string[]) => { diff --git a/chat2db-community-client/src/pages/main/organization/components/OrgSettings/index.tsx b/chat2db-community-client/src/pages/main/organization/components/OrgSettings/index.tsx index 30ff94b75d..78025166b9 100644 --- a/chat2db-community-client/src/pages/main/organization/components/OrgSettings/index.tsx +++ b/chat2db-community-client/src/pages/main/organization/components/OrgSettings/index.tsx @@ -39,16 +39,24 @@ function OrgSetting() { const [userOptions, setUserOptions] = useState([]); const requestGenerationRef = useRef(0); - if (!curOrg) return null; - - const isOwner = useMemo(() => curOrg?.roleCodes?.find((v) => v === OrgUserRoleCode.SUPER_ADMIN), [curOrg]); + // Hooks must run unconditionally on every render. The early return below + // previously sat above useMemo/useEffect, causing a Rules-of-Hooks violation + // ("Rendered more hooks than during the previous render") when curOrg + // transitioned from null to populated. Guard inside the hooks instead. + const isOwner = useMemo( + () => curOrg?.roleCodes?.includes(OrgUserRoleCode.SUPER_ADMIN) ?? false, + [curOrg], + ); useEffect(() => { + if (!curOrg) return; queryUserList(); return () => { invalidateLatestRequest(requestGenerationRef); }; - }, []); + }, [curOrg]); + + if (!curOrg) return null; const queryUserList = async () => { const requestGeneration = beginLatestRequest(requestGenerationRef); diff --git a/chat2db-community-client/src/pages/main/workspace/components/LocalSQLFileTree/index.tsx b/chat2db-community-client/src/pages/main/workspace/components/LocalSQLFileTree/index.tsx index f244e1afa4..93a70828aa 100644 --- a/chat2db-community-client/src/pages/main/workspace/components/LocalSQLFileTree/index.tsx +++ b/chat2db-community-client/src/pages/main/workspace/components/LocalSQLFileTree/index.tsx @@ -20,6 +20,7 @@ import PortalContextMenu from '@/components/ContextMenu/PortalContextMenu'; import type { ContextMenuAction, ContextMenuEntry, ContextMenuIntent } from '@/components/ContextMenu/core'; import { LOCAL_SQL_FILE_SAVED_EVENT, LOCAL_SQL_SESSION_DRAG_TYPE, WorkspaceTabType } from '@/constants'; import { runtimeEditionConfig } from '@/constants/runtimeEdition'; +import { DEFAULT_TERMINAL_SETTINGS } from '@/constants/terminal'; import { getEffectiveShortcutConfigMap, isShortcutEventMatch, @@ -348,6 +349,10 @@ const LocalSQLFileTree = forwardRef( const [dropTargetKey, setDropTargetKey] = useState(); const selectedRowRef = React.useRef(null); const shortcutOverrides = useGlobalStore((state) => state.shortcutOverrides); + const terminalShellId = useGlobalStore((state) => state.terminalSettings.shellId); + const terminalOpenPosition = useGlobalStore( + (state) => state.terminalSettings.openPosition || DEFAULT_TERMINAL_SETTINGS.openPosition, + ); const shortcutConfig = useMemo( () => getEffectiveShortcutConfigMap(shortcutOverrides as ShortcutOverrides), [shortcutOverrides], @@ -1095,10 +1100,31 @@ const LocalSQLFileTree = forwardRef( async function openNodeTerminal(node: LocalSQLFileTreeNode) { try { - await jcefApi.openSqlDirectoryTerminal({ + const terminal = await jcefApi.createSqlDirectoryTerminal({ rootToken: node.rootToken, relativePath: node.relativePath, + columns: 100, + rows: 30, + shellId: terminalShellId, }); + const id = `${WorkspaceTabType.Terminal}:${terminal.sessionId}`; + const nextTabs = [ + ...(workspaceTabList || []), + { + id, + type: WorkspaceTabType.Terminal, + title: `${i18n('workspace.terminal.title')}: ${node.type === 'directory' ? node.name : terminal.cwd}`, + uniqueData: { + terminalSessionId: terminal.sessionId, + terminalCwd: terminal.cwd, + terminalShell: terminal.shell, + terminalShellId: terminal.shellId, + terminalOpenPosition, + }, + }, + ]; + setWorkspaceTabList(nextTabs); + setActiveConsoleId(id); } catch (error) { console.error('open sql directory terminal error', error); feedback.error(i18n('workspace.localSqlFileTree.openTerminalFailed')); @@ -1130,11 +1156,15 @@ const LocalSQLFileTree = forwardRef( } function openFile(node: LocalSQLFileTreeNode) { - if (node.type !== 'file' || node.disabled || (!node.textFile && !node.sqlFile)) { + if (node.type !== 'file' || node.disabled || (!node.textFile && !node.sqlFile && !node.previewFile)) { return; } setSelectedNodeKey(node.key); - readFile(node.path, node.fileExtension); + readFile(node.path, node.fileExtension, { + rootToken: node.rootToken, + relativePath: node.relativePath, + previewFile: node.previewFile, + }); } function handleSelectedNodeOpen() { @@ -1415,11 +1445,13 @@ const LocalSQLFileTree = forwardRef( ]; if (node.type === 'file') { + const canOpen = node.textFile || node.sqlFile || node.previewFile; return [ createContextMenuAction({ id: 'open', label: i18n('workspace.localSqlFileTree.open'), shortcutAction: ShortcutAction.LocalSqlFileTreeOpen, + disabled: !canOpen, execute: () => openFile(node), }), { diff --git a/chat2db-community-client/src/pages/main/workspace/components/LocalSQLFileTree/type.ts b/chat2db-community-client/src/pages/main/workspace/components/LocalSQLFileTree/type.ts index 583a70b411..36d48c99de 100644 --- a/chat2db-community-client/src/pages/main/workspace/components/LocalSQLFileTree/type.ts +++ b/chat2db-community-client/src/pages/main/workspace/components/LocalSQLFileTree/type.ts @@ -13,6 +13,7 @@ export interface LocalSQLFileTreeNode { disabled?: boolean; sqlFile?: boolean; textFile?: boolean; + previewFile?: boolean; fileExtension?: string; hasChildren?: boolean; loaded?: boolean; diff --git a/chat2db-community-client/src/pages/main/workspace/components/SQLExecute/index.tsx b/chat2db-community-client/src/pages/main/workspace/components/SQLExecute/index.tsx index cfac94480e..de2e9d27d0 100644 --- a/chat2db-community-client/src/pages/main/workspace/components/SQLExecute/index.tsx +++ b/chat2db-community-client/src/pages/main/workspace/components/SQLExecute/index.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, + useReducer, useRef, useState, useCallback, @@ -9,6 +10,7 @@ import { ForwardedRef, useImperativeHandle, } from 'react'; +import { beginLatestRequest, invalidateLatestRequest, isLatestRequest } from '@/utils/latestRequest'; import SearchResult from '@/blocks/SearchResult'; import { createExecutionConsoleKeepHistoryStorageKey, @@ -17,8 +19,21 @@ import { readExecutionConsoleKeepHistory, subscribeExecutionConsoleKeepHistory, } from '@/blocks/SearchResult/components/ExecutionConsole/executionConsolePreferences'; +import { + createResultTabKeepHistoryStorageKey, + getResultTabPreferenceStorage, + persistResultTabKeepHistory, + readResultTabKeepHistory, + subscribeResultTabKeepHistory, +} from '@/blocks/SearchResult/resultTabPreferences'; import { useWorkspaceStore } from '@/store/workspace'; -import { IConsoleReturnExecuteSql, IBoundInfo, IDatabaseBaseInfo, IManageResultData } from '@/typings'; +import { + IConsoleReturnExecuteSql, + IBoundInfo, + IDatabaseBaseInfo, + IManageResultData, + IExecuteSqlParams, +} from '@/typings'; import { Spin } from 'antd'; import { useStyles } from './style'; import { useUpdateEffect } from 'ahooks'; @@ -32,16 +47,31 @@ import useSqlExecutor from '@/hooks/useSqlExecutor'; import i18n from '@/i18n'; import { staticMessage } from '@chat2db/ui'; import { + ClosedSqlExecutionResults, SqlExecutionEvent, - SqlExecutionStatement, - appendResultStarted, - appendResultFinished, + SqlExecutionResultIdentity, + appendCompletedQueryResult, attachExecutionIdentity, + clearClosedSqlExecutionResults, + isSqlExecutionResultClosed, + markSqlExecutionResultsClosed, mergeRows, sortExecutionResults, upsertResultFinished, upsertResultStarted, } from '@/service/sqlExecutionStream'; +import { + createSqlResultHistoryMode, + getNextResultDisplayBatchSequence, + getSqlResultPreview, + reduceSqlResultHistoryMode, + retainLatestResultBatches, + shouldAcceptExecutionResult, +} from '@/service/sqlExecutionBatch'; +import { + planSqlExecutionRetention, + type SqlExecutionRetentionPreferences, +} from '@/service/sqlExecutionRetention'; import { beginWebSqlExecution, clearSqlExecutionLog, @@ -58,11 +88,12 @@ import { isDesktop } from '@/utils/env'; import { v4 as uuidv4 } from 'uuid'; const SplitPaneAny = SplitPane as any; -const HISTORY_RESULT_LIMIT = 30; +const HISTORY_BATCH_LIMIT = 30; const KEEP_EXECUTION_LOG_HISTORY_STORAGE_KEY = createExecutionConsoleKeepHistoryStorageKey( 'community', __RUNTIME_ENV__, ); +const KEEP_RESULT_HISTORY_STORAGE_KEY = createResultTabKeepHistoryStorageKey('community', __RUNTIME_ENV__); interface IProps { boundInfo: IBoundInfo; @@ -75,6 +106,12 @@ interface IProps { onExecuteSQLCallback?: (params: { databaseInfo: IDatabaseBaseInfo; data: any }) => void; isConsole?: boolean; sqlActionEnabled?: boolean; + onEditorChange?: (value: string) => void; +} + +interface DesktopExecutionCallbackState { + databaseInfo: IDatabaseBaseInfo; + data: IManageResultData[]; } export interface SQLExecuteRef { @@ -82,47 +119,20 @@ export interface SQLExecuteRef { getDatabaseInfo: () => IDatabaseBaseInfo; } -function getSqlPreview(sql?: string) { - const lines = (sql || '') - .replace(/\r\n/g, '\n') - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); - const targetLine = lines.find((line) => !line.startsWith('--')) || lines[0] || ''; - return targetLine.replace(/\s+/g, ' '); -} - -function getSqlIdentity(sql?: string) { - return (sql || '') - .replace(/\r\n/g, '\n') - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('--')) - .join(' ') - .replace(/\s+/g, ' ') - .replace(/;+\s*$/, '') - .trim(); -} - -function getHistorySequenceKey(sql?: string) { - return getSqlIdentity(sql).toLowerCase(); -} - function getResultDisplayName(params: { executionSequence: number; + statementSequence: number; resultSequence: number; sql?: string; - showPrefix: boolean; }) { - const { executionSequence, resultSequence, sql, showPrefix } = params; - const preview = getSqlPreview(sql); + const { executionSequence, statementSequence, resultSequence, sql } = params; + const preview = getSqlResultPreview(sql); const maxLength = 36; const shortPreview = preview.length > maxLength ? `${preview.slice(0, maxLength - 1)}...` : preview; - if (!showPrefix) { - return shortPreview; - } - const resultNamePrefix = `#${executionSequence}-${resultSequence}`; - return shortPreview ? `${resultNamePrefix} ${shortPreview}` : resultNamePrefix; + const resultNamePrefix = `#${executionSequence}-${statementSequence}`; + const resultSetSuffix = resultSequence > 1 ? ` ${i18n('common.text.executionResult', resultSequence)}` : ''; + const label = `${resultNamePrefix}${resultSetSuffix}`; + return shortPreview ? `${label} ${shortPreview}` : label; } function getResultSequence(result: IManageResultData) { @@ -137,29 +147,11 @@ function buildResultKey(executionId: string, statementSequence?: number, streamR return [executionId, statementSequence || 0, streamResultId || 0].join(':'); } -function buildHistoryKey(boundInfo: IBoundInfo, sql?: string, historySequence?: number, resultSequence?: number) { - return [ - boundInfo.dataSourceId || '', - boundInfo.databaseName || '', - boundInfo.schemaName || '', - getSqlIdentity(sql), - historySequence || 1, - resultSequence || '', - ].join('|'); -} - -function archiveResultDataList(historyResultDataList: IManageResultData[], currentResultDataList: IManageResultData[]) { - if (!currentResultDataList.length) { - return historyResultDataList; - } - return [...currentResultDataList, ...historyResultDataList].slice(0, HISTORY_RESULT_LIMIT); -} - -function getEventStatementSequence(event: SqlExecutionEvent, fallback?: SqlExecutionStatement) { +function getEventStatementSequence(event: SqlExecutionEvent, fallback?: number) { if (typeof event.statementSequence === 'number') { return event.statementSequence; } - return fallback?.sequence; + return fallback; } function getEventResultSequence(event: SqlExecutionEvent, result?: IManageResultData, fallback?: number) { @@ -195,6 +187,7 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) onExecuteSQLCallback, isConsole = true, sqlActionEnabled = true, + onEditorChange, } = props; const { styles, cx } = useStyles(); const sqlEditorRef = useRef(null); @@ -203,14 +196,18 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) const editorId = boundInfo.workspaceTabId ?? boundInfo.consoleId; const [boxRightConsoleHeight, setBoxRightConsoleHeight] = useState(0); const executionSequenceRef = useRef(0); - const desktopReplacementRequestSequenceRef = useRef(0); + const resultDisplayBatchSequenceRef = useRef(0); + const resultDisplayBatchSequenceByExecutionRef = useRef>({}); + const latestResultReplacementExecutionSequenceRef = useRef(0); + const pendingDesktopExecutionSequenceRef = useRef(); const executionSequenceByIdRef = useRef>({}); - const statementSequenceRef = useRef(0); - const statementSqlCountsRef = useRef>({}); - const statementBySequenceRef = useRef>({}); - const currentStatementRef = useRef(); + const executionSequenceByRequestRef = useRef>({}); + const keepExistingOutputByExecutionSequenceRef = useRef>({}); + const desktopExecutionCallbackBySequenceRef = useRef>({}); + const currentStatementSequenceByExecutionIdRef = useRef>({}); const [resultBatchKey, setResultBatchKey] = useState(0); const [forceOutputTab, setForceOutputTab] = useState(false); + const requestGenerationRef = useRef(0); const { activeConsoleId, setEditorToList, deleteEditor, updateWorkspaceTabBoundInfo } = useWorkspaceStore( (state) => ({ activeConsoleId: state.activeConsoleId, @@ -220,9 +217,7 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) }), ); const [resultDataList, setResultDataList] = useState([]); - const resultDataListRef = useRef([]); - const [historyResultDataList, setHistoryResultDataList] = useState([]); - const historyResultDataListRef = useRef([]); + const closedSqlExecutionResultsRef = useRef(new Map()); const [sqlExecutionLogState, setSqlExecutionLogState] = useState(createSqlExecutionLogState); const [keepExecutionLogHistory, setKeepExecutionLogHistory] = useState(() => readExecutionConsoleKeepHistory( @@ -230,7 +225,25 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) KEEP_EXECUTION_LOG_HISTORY_STORAGE_KEY, ), ); - const handleClearExecutionLog = useCallback(() => setSqlExecutionLogState(clearSqlExecutionLog), []); + const [resultHistoryMode, dispatchResultHistoryMode] = useReducer( + reduceSqlResultHistoryMode, + undefined, + () => + createSqlResultHistoryMode( + readResultTabKeepHistory( + getResultTabPreferenceStorage(), + KEEP_RESULT_HISTORY_STORAGE_KEY, + ), + ), + ); + const { + keepHistory: keepResultHistory, + showResultCoordinates, + resetResultSessionOnNextExecution, + } = resultHistoryMode; + const handleClearExecutionLog = useCallback(() => { + setSqlExecutionLogState(clearSqlExecutionLog); + }, []); const handleKeepExecutionLogHistoryChange = useCallback((keepHistory: boolean) => { setKeepExecutionLogHistory(keepHistory); persistExecutionConsoleKeepHistory( @@ -248,117 +261,171 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) }), [], ); - const archivedForExecutionRef = useRef(false); - const executionSnapshotRef = useRef<{ - resultDataList: IManageResultData[]; - historyResultDataList: IManageResultData[]; - } | null>(null); - const handleRefreshTreeByExecuteSQL = useRefreshTree({ setBoundInfo }); - const archiveCurrentResultDataList = useCallback(() => { - if (archivedForExecutionRef.current) { - return; - } - archivedForExecutionRef.current = true; - const currentResultDataList = resultDataListRef.current; - if (currentResultDataList.length) { - const nextHistoryResultDataList = archiveResultDataList(historyResultDataListRef.current, currentResultDataList); - historyResultDataListRef.current = nextHistoryResultDataList; - resultDataListRef.current = []; - setHistoryResultDataList(nextHistoryResultDataList); - setResultDataList([]); - } - setResultBatchKey((value) => value + 1); - }, []); - const restoreExecutionSnapshotIfEmpty = useCallback(() => { - const executionSnapshot = executionSnapshotRef.current; - if (!executionSnapshot || resultDataListRef.current.length) { - return; - } - resultDataListRef.current = executionSnapshot.resultDataList; - historyResultDataListRef.current = executionSnapshot.historyResultDataList; - setResultDataList(executionSnapshot.resultDataList); - setHistoryResultDataList(executionSnapshot.historyResultDataList); - setResultBatchKey((value) => value + 1); + const handleKeepResultHistoryChange = useCallback((keepHistory: boolean) => { + dispatchResultHistoryMode({ type: 'setPreference', keepHistory }); + persistResultTabKeepHistory( + getResultTabPreferenceStorage(), + KEEP_RESULT_HISTORY_STORAGE_KEY, + keepHistory, + ); }, []); - const getExecutionSequence = useCallback((executionId: string) => { + useEffect( + () => + subscribeResultTabKeepHistory((storageKey, keepHistory) => { + if (storageKey === KEEP_RESULT_HISTORY_STORAGE_KEY) { + dispatchResultHistoryMode({ type: 'setPreference', keepHistory }); + } + }), + [], + ); + const handleRefreshTreeByExecuteSQL = useRefreshTree({ setBoundInfo }); + const getExecutionSequence = useCallback((executionId: string, preferredSequence?: number) => { if (!executionSequenceByIdRef.current[executionId]) { - const nextExecutionSequence = executionSequenceRef.current + 1; - executionSequenceRef.current = nextExecutionSequence; + const nextExecutionSequence = preferredSequence ?? executionSequenceRef.current + 1; + executionSequenceRef.current = Math.max(executionSequenceRef.current, nextExecutionSequence); executionSequenceByIdRef.current[executionId] = nextExecutionSequence; } return executionSequenceByIdRef.current[executionId]; }, []); - const resetCurrentExecutionState = useCallback((resetExecutionSequence = false) => { - if (resetExecutionSequence) { - executionSequenceRef.current = 0; - executionSequenceByIdRef.current = {}; + const getDisplayBatchSequence = useCallback((executionSequence: number) => { + const existingSequence = resultDisplayBatchSequenceByExecutionRef.current[executionSequence]; + if (existingSequence !== undefined) { + return existingSequence; + } + const displayBatchSequence = getNextResultDisplayBatchSequence( + resultDisplayBatchSequenceRef.current, + false, + ); + resultDisplayBatchSequenceRef.current = displayBatchSequence; + resultDisplayBatchSequenceByExecutionRef.current[executionSequence] = displayBatchSequence; + return displayBatchSequence; + }, []); + const beginExecutionBatch = useCallback( + (retentionPreferences: SqlExecutionRetentionPreferences) => { + const { + keepResultHistory: keepResultHistoryForExecution, + resetResultSession, + } = retentionPreferences; + const { keepExistingOutput, keepExistingResults } = planSqlExecutionRetention(retentionPreferences); + const executionSequence = executionSequenceRef.current + 1; + executionSequenceRef.current = executionSequence; + const displayBatchSequence = getNextResultDisplayBatchSequence( + resultDisplayBatchSequenceRef.current, + resetResultSession, + ); + resultDisplayBatchSequenceRef.current = displayBatchSequence; + resultDisplayBatchSequenceByExecutionRef.current[executionSequence] = displayBatchSequence; + keepExistingOutputByExecutionSequenceRef.current[executionSequence] = keepExistingOutput; + dispatchResultHistoryMode({ + type: 'beginExecution', + keepHistory: keepResultHistoryForExecution, + }); + setForceOutputTab(false); + if (!keepExistingResults) { + latestResultReplacementExecutionSequenceRef.current = executionSequence; + setResultDataList([]); + } + setResultBatchKey((value) => value + 1); + return { + executionSequence, + displayBatchSequence, + keepExistingOutput, + }; + }, + [], + ); + const cleanupDesktopExecutionRequest = useCallback((requestSequence: number, executionId?: string) => { + const executionSequence = executionSequenceByRequestRef.current[requestSequence]; + if (executionId) { + delete currentStatementSequenceByExecutionIdRef.current[executionId]; + delete executionSequenceByIdRef.current[executionId]; + clearClosedSqlExecutionResults(closedSqlExecutionResultsRef.current, executionId); + } + delete executionSequenceByRequestRef.current[requestSequence]; + if (executionSequence !== undefined) { + delete keepExistingOutputByExecutionSequenceRef.current[executionSequence]; + delete resultDisplayBatchSequenceByExecutionRef.current[executionSequence]; + delete desktopExecutionCallbackBySequenceRef.current[executionSequence]; } - statementSequenceRef.current = 0; - statementSqlCountsRef.current = {}; - statementBySequenceRef.current = {}; - currentStatementRef.current = undefined; }, []); - const beginExecutionResultBatch = useCallback(() => { - setForceOutputTab(false); - executionSnapshotRef.current = { - resultDataList: resultDataListRef.current, - historyResultDataList: historyResultDataListRef.current, - }; - archivedForExecutionRef.current = false; - archiveCurrentResultDataList(); - resetCurrentExecutionState(false); - }, [archiveCurrentResultDataList, resetCurrentExecutionState]); const handleSqlExecutionRequestStart = useCallback( (requestSequence: number) => { - if (!keepExecutionLogHistory) { - desktopReplacementRequestSequenceRef.current = requestSequence; - } + const executionSequence = pendingDesktopExecutionSequenceRef.current ?? executionSequenceRef.current; + pendingDesktopExecutionSequenceRef.current = undefined; + executionSequenceByRequestRef.current[requestSequence] = executionSequence; + const keepExistingOutput = + keepExistingOutputByExecutionSequenceRef.current[executionSequence] ?? keepExecutionLogHistory; setSqlExecutionLogState((state) => - prepareDesktopSqlExecutionLogForRequest(state, requestSequence, keepExecutionLogHistory), + prepareDesktopSqlExecutionLogForRequest(state, requestSequence, keepExistingOutput), ); }, [keepExecutionLogHistory], ); + const handleSqlExecutionRequestStartError = useCallback( + (error: unknown, requestSequence: number, params: IExecuteSqlParams) => { + const executionSequence = executionSequenceByRequestRef.current[requestSequence]; + if (executionSequence !== undefined) { + setSqlExecutionLogState((state) => + failWebSqlExecution(state, { + executionId: `desktop-start-${requestSequence}`, + executionSequence, + sql: params.sql, + context: getExecutionLogContext(boundInfoRef.current), + error, + }), + ); + } + cleanupDesktopExecutionRequest(requestSequence); + }, + [cleanupDesktopExecutionRequest], + ); const handleSqlExecutionEvent = useCallback( (event: SqlExecutionEvent, requestSequence: number) => { - if (requestSequence < desktopReplacementRequestSequenceRef.current) { + const requestExecutionSequence = executionSequenceByRequestRef.current[requestSequence]; + if (requestExecutionSequence === undefined) { return; } + const cleanupTerminalExecution = () => { + if (event.eventType !== 'finished' && event.eventType !== 'failed' && event.eventType !== 'cancelled') { + return; + } + cleanupDesktopExecutionRequest(requestSequence, event.executionId); + }; + const executionSequence = getExecutionSequence(event.executionId, requestExecutionSequence); + const keepExistingOutput = + keepExistingOutputByExecutionSequenceRef.current[executionSequence] ?? keepExecutionLogHistory; setSqlExecutionLogState((state) => reduceDesktopSqlExecutionEventWithHistoryPreference( state, event, getExecutionLogContext(boundInfoRef.current), - keepExecutionLogHistory, + keepExistingOutput, requestSequence, + executionSequence, ), ); - // Execution always replaces the previous result; use View History to inspect older results. - const shouldOverwriteResultTabs = true; + if ( + !shouldAcceptExecutionResult( + executionSequence, + latestResultReplacementExecutionSequenceRef.current, + ) + ) { + cleanupTerminalExecution(); + return; + } + const displayBatchSequence = getDisplayBatchSequence(executionSequence); if (event.eventType === 'started') { - archiveCurrentResultDataList(); - resetCurrentExecutionState(false); return; } if (event.eventType === 'statementStarted') { - const statementSequence = event.statementSequence ?? statementSequenceRef.current + 1; - statementSequenceRef.current = Math.max(statementSequenceRef.current, statementSequence); - const statementSqlIdentity = getHistorySequenceKey(event.message?.originalSql || event.message?.sql); - const statementSqlCount = (statementSqlCountsRef.current[statementSqlIdentity] || 0) + 1; - statementSqlCountsRef.current[statementSqlIdentity] = statementSqlCount; - const nextStatement = { - ...(event.message || {}), - sequence: statementSequence, - historySequence: statementSqlCount, - }; - currentStatementRef.current = nextStatement; - statementBySequenceRef.current[statementSequence] = nextStatement; + const statementSequence = + event.statementSequence ?? (currentStatementSequenceByExecutionIdRef.current[event.executionId] || 0) + 1; + currentStatementSequenceByExecutionIdRef.current[event.executionId] = statementSequence; return; } if (event.eventType === 'resultStarted') { - const statementSequence = getEventStatementSequence(event, currentStatementRef.current); - const statementSnapshot = - (statementSequence && statementBySequenceRef.current[statementSequence]) || currentStatementRef.current; + const statementSequence = + getEventStatementSequence(event, currentStatementSequenceByExecutionIdRef.current[event.executionId]) || 1; const result = processResultDataList([event.message], { databaseType: boundInfoRef.current.databaseType, dataSourceId: boundInfoRef.current.dataSourceId, @@ -370,14 +437,10 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) const resultSequence = getEventResultSequence(event, resultWithIdentity, getResultSequence(resultWithIdentity)) || 1; const resultKey = event.resultKey || buildResultKey(event.executionId, statementSequence, resultSequence); - const historyKey = buildHistoryKey( - boundInfoRef.current, - resultWithIdentity.originalSql, - statementSnapshot?.historySequence, - resultSequence, - ); + if (isSqlExecutionResultClosed(closedSqlExecutionResultsRef.current, event.executionId, resultKey)) { + return; + } setResultDataList((prev) => { - const executionSequence = getExecutionSequence(event.executionId); const nextResult = { ...resultWithIdentity, extra: { @@ -385,50 +448,70 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) executionSequence, resultKey, resultSequence, - historyKey, }, displayName: getResultDisplayName({ - executionSequence, - resultSequence, + executionSequence: displayBatchSequence, + statementSequence, + resultSequence: resultWithIdentity.resultSetId || resultSequence, sql: resultWithIdentity.originalSql, - showPrefix: !shouldOverwriteResultTabs, }), }; - const nextResultDataList = shouldOverwriteResultTabs - ? appendResultStarted(prev, nextResult) - : upsertResultStarted(prev, nextResult); - const sortedResultDataList = sortExecutionResults(nextResultDataList); - resultDataListRef.current = sortedResultDataList; + const nextResultDataList = upsertResultStarted(prev, nextResult); + const sortedResultDataList = retainLatestResultBatches( + sortExecutionResults(nextResultDataList), + HISTORY_BATCH_LIMIT, + ); return sortedResultDataList; }); return; } if (event.eventType === 'rows') { - const statementSequence = getEventStatementSequence(event, currentStatementRef.current); - const resultSequence = getEventResultSequence(event, event.message); - const chunkWithIdentity = attachExecutionIdentity( - event.message, - event.executionId, - statementSequence, - ); + const statementSequence = + getEventStatementSequence(event, currentStatementSequenceByExecutionIdRef.current[event.executionId]) || 1; + const chunk = processResultDataList([event.message], { + databaseType: boundInfoRef.current.databaseType, + dataSourceId: boundInfoRef.current.dataSourceId, + databaseName: boundInfoRef.current.databaseName, + schemaName: boundInfoRef.current.schemaName, + sql: event.message?.originalSql, + })[0]; + const chunkWithIdentity = attachExecutionIdentity(chunk, event.executionId, statementSequence); + const resultSequence = + getEventResultSequence(event, chunkWithIdentity, getResultSequence(chunkWithIdentity)) || 1; + const resultKey = event.resultKey || buildResultKey(event.executionId, statementSequence, resultSequence); + if (isSqlExecutionResultClosed(closedSqlExecutionResultsRef.current, event.executionId, resultKey)) { + return; + } setResultDataList((prev) => { const nextResultDataList = mergeRows(prev, { ...chunkWithIdentity, + displayName: getResultDisplayName({ + executionSequence: displayBatchSequence, + statementSequence, + resultSequence: chunkWithIdentity.resultSetId || resultSequence, + sql: chunkWithIdentity.originalSql, + }), extra: { ...(chunkWithIdentity.extra || {}), - resultKey: event.resultKey || buildResultKey(event.executionId, statementSequence, resultSequence), + executionSequence, + resultKey, resultSequence, }, }); - resultDataListRef.current = nextResultDataList; - return nextResultDataList; + const retainedResultDataList = retainLatestResultBatches(nextResultDataList, HISTORY_BATCH_LIMIT); + return retainedResultDataList; }); return; } if (event.eventType === 'updateCount' || event.eventType === 'resultFinished') { - const statementSequence = getEventStatementSequence(event, currentStatementRef.current); - const statementSnapshot = - (statementSequence && statementBySequenceRef.current[statementSequence]) || currentStatementRef.current; + if (event.eventType === 'resultFinished') { + const callbackState = desktopExecutionCallbackBySequenceRef.current[executionSequence]; + if (callbackState) { + callbackState.data = appendCompletedQueryResult(callbackState.data, event); + } + } + const statementSequence = + getEventStatementSequence(event, currentStatementSequenceByExecutionIdRef.current[event.executionId]) || 1; const result = processResultDataList([event.message], { databaseType: boundInfoRef.current.databaseType, dataSourceId: boundInfoRef.current.dataSourceId, @@ -440,37 +523,31 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) const resultSequence = getEventResultSequence(event, resultWithIdentity, getResultSequence(resultWithIdentity)) || 1; const resultKey = event.resultKey || buildResultKey(event.executionId, statementSequence, resultSequence); - const historyKey = buildHistoryKey( - boundInfoRef.current, - resultWithIdentity.originalSql, - statementSnapshot?.historySequence, - resultSequence, - ); - setResultDataList((prev) => { - const executionSequence = getExecutionSequence(event.executionId); - const nextResult = { - ...resultWithIdentity, - displayName: getResultDisplayName({ - executionSequence, - resultSequence, - sql: resultWithIdentity.originalSql, - showPrefix: !shouldOverwriteResultTabs, - }), - extra: { - ...(resultWithIdentity.extra || {}), - executionSequence, - resultKey, - resultSequence, - historyKey, - }, - }; - const nextResultDataList = shouldOverwriteResultTabs - ? appendResultFinished(prev, nextResult) - : upsertResultFinished(prev, nextResult); - const sortedResultDataList = sortExecutionResults(nextResultDataList); - resultDataListRef.current = sortedResultDataList; - return sortedResultDataList; - }); + if (!isSqlExecutionResultClosed(closedSqlExecutionResultsRef.current, event.executionId, resultKey)) { + setResultDataList((prev) => { + const nextResult = { + ...resultWithIdentity, + displayName: getResultDisplayName({ + executionSequence: displayBatchSequence, + statementSequence, + resultSequence: resultWithIdentity.resultSetId || resultSequence, + sql: resultWithIdentity.originalSql, + }), + extra: { + ...(resultWithIdentity.extra || {}), + executionSequence, + resultKey, + resultSequence, + }, + }; + const nextResultDataList = upsertResultFinished(prev, nextResult); + const sortedResultDataList = retainLatestResultBatches( + sortExecutionResults(nextResultDataList), + HISTORY_BATCH_LIMIT, + ); + return sortedResultDataList; + }); + } if (event.eventType === 'resultFinished' && boundInfoRef.current.databaseType) { handleRefreshTreeByExecuteSQL([result], boundInfoRef.current.databaseType); } @@ -479,36 +556,50 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) if (event.eventType === 'message') { return; } + if (event.eventType === 'finished' || event.eventType === 'failed' || event.eventType === 'cancelled') { + try { + const callbackState = desktopExecutionCallbackBySequenceRef.current[executionSequence]; + if (event.eventType === 'finished' && callbackState?.data.length) { + onExecuteSQLCallback?.(callbackState); + } + } finally { + cleanupTerminalExecution(); + } + } }, [ - archiveCurrentResultDataList, + cleanupDesktopExecutionRequest, + getDisplayBatchSequence, getExecutionSequence, handleRefreshTreeByExecuteSQL, keepExecutionLogHistory, - resetCurrentExecutionState, + onExecuteSQLCallback, ], ); - const { executing, executeSQL, stopExecuteSQL } = useSqlExecutor({ + const { executing, canExecuteSQL, executeSQL, stopExecuteSQL } = useSqlExecutor({ onExecutionRequestStart: handleSqlExecutionRequestStart, + onExecutionRequestStartError: handleSqlExecutionRequestStartError, onExecutionEvent: handleSqlExecutionEvent, + onExecutionCancellationError: () => { + staticMessage.error(i18n('common.text.cancelRequestFailed')); + }, }); // Whether to show the split panel. const isSplitPane = useMemo(() => { const _isSplitPane = resultDataList.length > 0 || - historyResultDataList.length > 0 || sqlExecutionLogState.records.length > 0 || executing === true; if (!_isSplitPane) { setBoxRightConsoleHeight(0); } return _isSplitPane; - }, [resultDataList, historyResultDataList, sqlExecutionLogState.records.length, executing]); + }, [resultDataList, sqlExecutionLogState.records.length, executing]); const isActive = useMemo(() => { return activeConsoleId === editorId || !!props.isActive; - }, [activeConsoleId, editorId]); + }, [activeConsoleId, editorId, props.isActive]); useEffect(() => { if (editorId) { @@ -527,8 +618,10 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) }, [boundInfo]); useEffect(() => { + const requestGeneration = beginLatestRequest(requestGenerationRef); if (loadSQL) { loadSQL().then((sql) => { + if (!isLatestRequest(requestGenerationRef, requestGeneration)) return; sqlEditorRef.current?.setValue(sql, 'reset'); updateWorkspaceTabBoundInfo({ ...boundInfoRef.current, @@ -536,6 +629,9 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) }); }); } + return () => { + invalidateLatestRequest(requestGenerationRef); + }; }, []); const handleUnfold = () => { @@ -547,16 +643,24 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) }; const handleResultDataListChange = useCallback( - (params: { resultDataList: IManageResultData[]; historyResultDataList: IManageResultData[] }) => { - if (!params.resultDataList.length && !params.historyResultDataList.length) { - resetCurrentExecutionState(true); - } - resultDataListRef.current = params.resultDataList; - historyResultDataListRef.current = params.historyResultDataList; - setResultDataList(params.resultDataList); - setHistoryResultDataList(params.historyResultDataList); + (params: { + resultDataList: IManageResultData[]; + historyResultDataList: IManageResultData[]; + closedResultIdentities: SqlExecutionResultIdentity[]; + }) => { + markSqlExecutionResultsClosed( + closedSqlExecutionResultsRef.current, + params.closedResultIdentities.filter( + ({ executionId }) => executionSequenceByIdRef.current[executionId] !== undefined, + ), + ); + const nextResultDataList = sortExecutionResults([ + ...params.resultDataList, + ...params.historyResultDataList, + ]); + setResultDataList(nextResultDataList); }, - [resetCurrentExecutionState], + [], ); const handleChangeDBInfo = (newBoundInfo: IBoundInfo) => { @@ -574,11 +678,22 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) staticMessage.warning(i18n('workspace.text.pleaseSelectDataSource')); return Promise.resolve(); } + if (!canExecuteSQL()) { + staticMessage.warning(i18n('common.text.currentExecution')); + return Promise.resolve(); + } if (!boxRightConsoleHeight) { setBoxRightConsoleHeight('50%'); } - beginExecutionResultBatch(); + const { executionSequence, displayBatchSequence, keepExistingOutput } = beginExecutionBatch({ + keepOutputHistory: keepExecutionLogHistory, + keepResultHistory, + resetResultSession: resetResultSessionOnNextExecution, + }); + if (isDesktop) { + pendingDesktopExecutionSequenceRef.current = executionSequence; + } const executeSqlParams = { ...params, @@ -591,12 +706,23 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) const webExecutionId = isDesktop ? undefined : uuidv4(); const executionLogContext = getExecutionLogContext(boundInfo); + if (isDesktop && onExecuteSQLCallback) { + desktopExecutionCallbackBySequenceRef.current[executionSequence] = { + databaseInfo: { + ...boundInfo, + ...params, + }, + data: [], + }; + } if (webExecutionId) { + executionSequenceByIdRef.current[webExecutionId] = executionSequence; setSqlExecutionLogState((state) => beginWebSqlExecution( - prepareSqlExecutionLogForExecution(state, webExecutionId, keepExecutionLogHistory), + prepareSqlExecutionLogForExecution(state, webExecutionId, keepExistingOutput), { executionId: webExecutionId, + executionSequence, sql: params.sql, context: executionLogContext, }, @@ -610,40 +736,35 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) setSqlExecutionLogState((state) => completeWebSqlExecution(state, { executionId: webExecutionId, + executionSequence, sql: params.sql, context: executionLogContext, results: [], }), ); } - executionSnapshotRef.current = null; return; } - const executionSequence = executionSequenceRef.current + 1; - executionSequenceRef.current = executionSequence; - // Execution always replaces the previous result; use View History to inspect older results. - const shouldOverwriteResultTabs = true; - const legacySqlCounts: Record = {}; const _resultDataList = processResultDataList(res, executeSqlParams).map((item, index) => { const sql = item.originalSql || params.sql; - const sqlIdentity = getHistorySequenceKey(sql); - const historySequence = (legacySqlCounts[sqlIdentity] || 0) + 1; - legacySqlCounts[sqlIdentity] = historySequence; - const resultSequence = item.resultSetId || index + 1; + const statementSequence = item.statementSequence ?? (Number(item.extra?.statementSequence) || index + 1); + const resultSequence = Number(item.extra?.streamResultId) || index + 1; + const executionId = webExecutionId || `legacy-${executionSequence}`; + const itemWithIdentity = attachExecutionIdentity(item, executionId, statementSequence); return { - ...item, + ...itemWithIdentity, extra: { - ...(item.extra || {}), + ...(itemWithIdentity.extra || {}), executionSequence, - resultKey: buildResultKey(webExecutionId || `legacy-${executionSequence}`, index + 1, resultSequence), + statementSequence, + resultKey: buildResultKey(executionId, statementSequence, resultSequence), resultSequence, - historyKey: buildHistoryKey(boundInfo, sql, historySequence, resultSequence), }, displayName: getResultDisplayName({ - executionSequence, - resultSequence: index + 1, + executionSequence: displayBatchSequence, + statementSequence, + resultSequence: item.resultSetId || resultSequence, sql, - showPrefix: !shouldOverwriteResultTabs, }), }; }); @@ -653,21 +774,29 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) handleRefreshTreeByExecuteSQL(_resultDataList, boundInfo.databaseType); } - setResultDataList((prev) => { - const nextResultDataList = shouldOverwriteResultTabs - ? _resultDataList - : _resultDataList.reduce((currentResultDataList, item) => { - return upsertResultFinished(currentResultDataList, item); - }, prev); - const sortedResultDataList = sortExecutionResults(nextResultDataList); - resultDataListRef.current = sortedResultDataList; - return sortedResultDataList; - }); + if ( + shouldAcceptExecutionResult( + executionSequence, + latestResultReplacementExecutionSequenceRef.current, + ) + ) { + setResultDataList((prev) => { + const nextResultDataList = _resultDataList.reduce((currentResultDataList, item) => { + return upsertResultFinished(currentResultDataList, item); + }, prev); + const sortedResultDataList = retainLatestResultBatches( + sortExecutionResults(nextResultDataList), + HISTORY_BATCH_LIMIT, + ); + return sortedResultDataList; + }); + } if (webExecutionId) { setSqlExecutionLogState((state) => completeWebSqlExecution(state, { executionId: webExecutionId, + executionSequence, sql: params.sql, context: executionLogContext, results: _resultDataList, @@ -675,8 +804,6 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) ); } - executionSnapshotRef.current = null; - const data = res.filter((item) => item.dataList !== null); onExecuteSQLCallback?.({ @@ -688,19 +815,29 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) }); }) .catch((error) => { - setForceOutputTab(true); + if (executionSequence === executionSequenceRef.current) { + setForceOutputTab(true); + } if (webExecutionId) { setSqlExecutionLogState((state) => failWebSqlExecution(state, { executionId: webExecutionId, + executionSequence, sql: params.sql, context: executionLogContext, error, }), ); } - restoreExecutionSnapshotIfEmpty(); rethrowNonCancellationSqlExecutionError(error); + }) + .finally(() => { + if (webExecutionId) { + delete executionSequenceByIdRef.current[webExecutionId]; + } + delete keepExistingOutputByExecutionSequenceRef.current[executionSequence]; + delete resultDisplayBatchSequenceByExecutionRef.current[executionSequence]; + delete desktopExecutionCallbackBySequenceRef.current[executionSequence]; }); }; @@ -750,21 +887,25 @@ const SQLExecute = forwardRef((props: IProps, ref: ForwardedRef) reloadSQL={loadSQL} isConsole={isConsole} sqlActionEnabled={sqlActionEnabled} + onChange={onEditorChange} />
{isSplitPane && ( <> - {!!(resultDataList.length || historyResultDataList.length || sqlExecutionLogState.records.length) && ( + {!!(resultDataList.length || sqlExecutionLogState.records.length) && ( )} diff --git a/chat2db-community-client/src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/index.tsx b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/index.tsx index af7bcfe820..3cb7941f50 100644 --- a/chat2db-community-client/src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/index.tsx +++ b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/index.tsx @@ -1,15 +1,20 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import classnames from 'classnames'; import i18n from '@/i18n'; import { extendConfig } from '../config'; -import { IconButton } from '@chat2db/ui'; +import { IconButton, staticMessage } from '@chat2db/ui'; import { useWorkspaceStore } from '@/store/workspace'; import { useImportExportStore } from '@/store/importExport'; import { useStyles } from './style'; -import { canImportExport } from '@/utils/env'; +import { canImportExport, isDesktop } from '@/utils/env'; import { Divider } from 'antd'; import { useAIStore } from '@/store/ai'; import AIButton from '@/blocks/AI/components/AIButton'; +import { Terminal } from 'lucide-react'; +import { useGlobalStore } from '@/store/global'; +import jcefApi from '@/jcef'; +import { createQuickTerminalTab } from './quickTerminal'; +import { DEFAULT_TERMINAL_SETTINGS } from '@/constants/terminal'; interface IToolbar { code: string; @@ -25,8 +30,14 @@ interface IProps { export default (props: IProps) => { const { className } = props; const { styles } = useStyles(); - const { currentWorkspaceExtend, setCurrentWorkspaceExtend } = useWorkspaceStore((state) => { + const [creatingTerminal, setCreatingTerminal] = useState(false); + const terminalShellId = useGlobalStore((state) => state.terminalSettings.shellId); + const terminalOpenPosition = useGlobalStore( + (state) => state.terminalSettings.openPosition || DEFAULT_TERMINAL_SETTINGS.openPosition, + ); + const { addWorkspaceTab, currentWorkspaceExtend, setCurrentWorkspaceExtend } = useWorkspaceStore((state) => { return { + addWorkspaceTab: state.addWorkspaceTab, currentWorkspaceExtend: state.currentWorkspaceExtend, setCurrentWorkspaceExtend: state.setCurrentWorkspaceExtend, }; @@ -49,6 +60,28 @@ export default (props: IProps) => { setCurrentWorkspaceExtend(item.code); }; + const createTerminal = async () => { + if (creatingTerminal) { + return; + } + setCreatingTerminal(true); + try { + const terminal = await jcefApi.createTerminal({ + columns: 100, + rows: 30, + shellId: terminalShellId, + }); + addWorkspaceTab( + createQuickTerminalTab(terminal, i18n('workspace.terminal.title'), terminalOpenPosition), + ); + } catch (error) { + console.error('create terminal error', error); + staticMessage.error(i18n('workspace.localSqlFileTree.openTerminalFailed')); + } finally { + setCreatingTerminal(false); + } + }; + useEffect(() => { useWorkspaceStore.getState().togglePanelRight(!!currentWorkspaceExtend || showAIPanel); }, [currentWorkspaceExtend, showAIPanel]); @@ -74,6 +107,18 @@ export default (props: IProps) => { })} + {isDesktop && ( + + )} { setCurrentWorkspaceExtend(null); diff --git a/chat2db-community-client/src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/quickTerminal.test.ts b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/quickTerminal.test.ts new file mode 100644 index 0000000000..56c25991ed --- /dev/null +++ b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/quickTerminal.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { WorkspaceTabType } from '@/constants/workspace'; +import { createQuickTerminalTab } from './quickTerminal'; + +const tab = createQuickTerminalTab( + { + sessionId: 'session-1', + cwd: '/Users/chat2db', + shell: 'Zsh', + shellId: 'zsh', + }, + 'Terminal', + 'right', +); + +assert.deepEqual(tab, { + id: `${WorkspaceTabType.Terminal}:session-1`, + type: WorkspaceTabType.Terminal, + title: 'Terminal', + uniqueData: { + terminalSessionId: 'session-1', + terminalCwd: '/Users/chat2db', + terminalShell: 'Zsh', + terminalShellId: 'zsh', + terminalOpenPosition: 'right', + }, +}); + +console.log('Quick terminal tab tests passed'); diff --git a/chat2db-community-client/src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/quickTerminal.ts b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/quickTerminal.ts new file mode 100644 index 0000000000..7696527f43 --- /dev/null +++ b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceExtend/WorkspaceExtendNav/quickTerminal.ts @@ -0,0 +1,29 @@ +import { WorkspaceTabType } from '@/constants/workspace'; +import type { IWorkspaceTab } from '@/typings'; +import type { TerminalOpenPosition } from '@/typings/settings'; + +export interface QuickTerminalSession { + sessionId: string; + cwd: string; + shell: string; + shellId: string; +} + +export function createQuickTerminalTab( + terminal: QuickTerminalSession, + title: string, + openPosition: TerminalOpenPosition = 'tab', +): IWorkspaceTab { + return { + id: `${WorkspaceTabType.Terminal}:${terminal.sessionId}`, + type: WorkspaceTabType.Terminal, + title, + uniqueData: { + terminalSessionId: terminal.sessionId, + terminalCwd: terminal.cwd, + terminalShell: terminal.shell, + terminalShellId: terminal.shellId, + terminalOpenPosition: openPosition, + }, + }; +} diff --git a/chat2db-community-client/src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnections.ts b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnections.ts index 9fba2e83af..0eff8476de 100644 --- a/chat2db-community-client/src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnections.ts +++ b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnections.ts @@ -1,12 +1,47 @@ import ConnectionServer from '@/service/connection'; +import feedback from '@/utils/feedback'; import {saveFileToDesktop} from '@/utils/file'; +import i18n from '@/i18n'; +import { runExportConnections } from './exportConnectionsFlow'; + +const EXPORT_CONNECTIONS_FEEDBACK_KEY = 'export-connections'; + +//#region 导出连接反馈 export const exportConnections = (params: { datasourceIds: number[] | null }) => { - ConnectionServer.exportDataSource(params).then((res) => { - saveFileToDesktop({ - fileName: `export_chat2db_connections`, - fileContent: res.message, - fileType: 'json' - }); + return runExportConnections(params, { + exportDataSource: ConnectionServer.exportDataSource, + saveFile: saveFileToDesktop, + onExporting: () => { + feedback.open({ + key: EXPORT_CONNECTIONS_FEEDBACK_KEY, + type: 'loading', + content: i18n('connection.export.exporting'), + duration: 0, + }); + }, + onSaved: () => { + feedback.open({ + key: EXPORT_CONNECTIONS_FEEDBACK_KEY, + type: 'success', + content: i18n('connection.export.success'), + }); + }, + onCancelled: () => { + feedback.open({ + key: EXPORT_CONNECTIONS_FEEDBACK_KEY, + type: 'info', + content: i18n('connection.export.cancelled'), + }); + }, + onFailed: () => { + feedback.open({ + key: EXPORT_CONNECTIONS_FEEDBACK_KEY, + type: 'error', + content: i18n('connection.export.failed'), + }); + }, }); }; + +//#endregion diff --git a/chat2db-community-client/src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnectionsFlow.test.ts b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnectionsFlow.test.ts new file mode 100644 index 0000000000..c5ac8bc4ff --- /dev/null +++ b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnectionsFlow.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { runExportConnections } from './exportConnectionsFlow'; + +const params = { datasourceIds: [1, 2] }; + +async function shouldReportSavedExport() { + const events: string[] = []; + let savedFile: { fileName: string; fileContent: string; fileType: string } | undefined; + + const outcome = await runExportConnections(params, { + exportDataSource: async () => ({ message: '{"connections":[]}' }), + saveFile: async (file) => { + savedFile = file; + return { path: '/tmp/export_chat2db_connections.json', size: 18 }; + }, + onExporting: () => events.push('exporting'), + onSaved: () => events.push('saved'), + onCancelled: () => events.push('cancelled'), + onFailed: () => events.push('failed'), + }); + + assert.equal(outcome, 'saved'); + assert.deepEqual(events, ['exporting', 'saved']); + assert.deepEqual(savedFile, { + fileName: 'export_chat2db_connections', + fileContent: '{"connections":[]}', + fileType: 'json', + }); +} + +async function shouldReportCancelledExport() { + const events: string[] = []; + + const outcome = await runExportConnections(params, { + exportDataSource: async () => ({ message: '{}' }), + saveFile: async () => null, + onExporting: () => events.push('exporting'), + onSaved: () => events.push('saved'), + onCancelled: () => events.push('cancelled'), + onFailed: () => events.push('failed'), + }); + + assert.equal(outcome, 'cancelled'); + assert.deepEqual(events, ['exporting', 'cancelled']); +} + +async function shouldReportExportFailure() { + const events: string[] = []; + + const outcome = await runExportConnections(params, { + exportDataSource: async () => { + throw new Error('request failed'); + }, + saveFile: async () => { + throw new Error('must not save'); + }, + onExporting: () => events.push('exporting'), + onSaved: () => events.push('saved'), + onCancelled: () => events.push('cancelled'), + onFailed: () => events.push('failed'), + }); + + assert.equal(outcome, 'failed'); + assert.deepEqual(events, ['exporting', 'failed']); +} + +async function shouldReportSaveFailure() { + const events: string[] = []; + + const outcome = await runExportConnections(params, { + exportDataSource: async () => ({ message: '{}' }), + saveFile: async () => { + throw new Error('save failed'); + }, + onExporting: () => events.push('exporting'), + onSaved: () => events.push('saved'), + onCancelled: () => events.push('cancelled'), + onFailed: () => events.push('failed'), + }); + + assert.equal(outcome, 'failed'); + assert.deepEqual(events, ['exporting', 'failed']); +} + +async function main() { + await shouldReportSavedExport(); + await shouldReportCancelledExport(); + await shouldReportExportFailure(); + await shouldReportSaveFailure(); + + console.log('Export connections flow tests passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/chat2db-community-client/src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnectionsFlow.ts b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnectionsFlow.ts new file mode 100644 index 0000000000..ca35620611 --- /dev/null +++ b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceLeftActionBar/components/TreeSetting/exportConnectionsFlow.ts @@ -0,0 +1,51 @@ +export type ExportConnectionsParams = { datasourceIds: number[] | null }; +export type ExportConnectionsOutcome = 'saved' | 'cancelled' | 'failed'; + +interface ExportFile { + fileName: string; + fileContent: string; + fileType: string; +} + +interface SavedFile { + path: string; + size: number; +} + +interface ExportConnectionsDependencies { + exportDataSource: (params: ExportConnectionsParams) => Promise<{ message: string }>; + saveFile: (file: ExportFile) => Promise | SavedFile | null | undefined; + onExporting: () => void; + onSaved: () => void; + onCancelled: () => void; + onFailed: (error: unknown) => void; +} + +//#region 导出连接流程 + +export async function runExportConnections( + params: ExportConnectionsParams, + dependencies: ExportConnectionsDependencies, +): Promise { + dependencies.onExporting(); + try { + const response = await dependencies.exportDataSource(params); + const savedFile = await dependencies.saveFile({ + fileName: 'export_chat2db_connections', + fileContent: response.message, + fileType: 'json', + }); + if (!savedFile) { + dependencies.onCancelled(); + return 'cancelled'; + } + + dependencies.onSaved(); + return 'saved'; + } catch (error) { + dependencies.onFailed(error); + return 'failed'; + } +} + +//#endregion diff --git a/chat2db-community-client/src/pages/main/workspace/components/WorkspaceTabs/FilePreviewTab.less b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceTabs/FilePreviewTab.less new file mode 100644 index 0000000000..1de6a0e325 --- /dev/null +++ b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceTabs/FilePreviewTab.less @@ -0,0 +1,287 @@ +.markdownWorkspace { + position: relative; + display: flex; + width: 100%; + height: 100%; + min-height: 0; + flex-direction: column; + background: var(--color-bg-base); +} + +.markdownToolbar { + position: absolute; + top: 8px; + right: 12px; + z-index: 3; + display: flex; + align-items: center; + justify-content: flex-end; + opacity: 0.3; + transition: opacity 160ms ease; + + &:hover, + &:focus-within { + opacity: 1; + } +} + +.markdownContent { + position: relative; + min-height: 0; + overflow: hidden; + flex: 1; +} + +.splitPane { + display: grid; + width: 100%; + height: 100%; + min-height: 0; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.sourcePane, +.reviewPane { + position: relative; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.splitPane > .sourcePane { + border-right: 1px solid var(--color-border); +} + +.viewModeIcon { + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + font-size: 15px; +} + +.mermaidRenderHost { + position: fixed; + top: 0; + left: -10000px; + width: 1000px; + height: 1px; + overflow: hidden; + visibility: hidden; + pointer-events: none; +} + +.markdownScroller { + height: 100%; + overflow: auto; + background: var(--color-bg-base); +} + +.markdown { + box-sizing: border-box; + max-width: 980px; + min-height: 100%; + margin: 0 auto; + padding: 32px 48px 80px; + color: var(--color-text); + font-size: 14px; + line-height: 1.72; + overflow-wrap: anywhere; + + h1, + h2 { + margin-top: 1.6em; + padding-bottom: 0.35em; + border-bottom: 1px solid var(--color-border); + } + + h1:first-child, + h2:first-child, + h3:first-child { + margin-top: 0; + } + + blockquote { + margin: 1em 0; + padding: 0.5em 1em; + color: var(--color-text-secondary); + border-left: 4px solid var(--color-primary); + background: var(--color-bg-subtle); + } + + table { + display: block; + width: max-content; + max-width: 100%; + overflow: auto; + border-spacing: 0; + border-collapse: collapse; + } + + th, + td { + padding: 8px 12px; + border: 1px solid var(--color-border); + } + + th { + font-weight: 600; + background: var(--color-bg-subtle); + } + + pre { + overflow: auto; + padding: 14px 16px; + border-radius: 8px; + background: #161b22; + color: #e6edf3; + } + + :not(pre) > code { + padding: 0.15em 0.4em; + border-radius: 4px; + background: var(--color-bg-subtle); + } + + img { + display: block; + max-width: 100%; + height: auto; + margin: 16px auto; + border-radius: 8px; + } + + input[type='checkbox'] { + margin-right: 0.45em; + } +} + +.diagram { + overflow: auto; + padding: 18px; + border-radius: 8px; + background: #fff; + text-align: center; + + svg { + max-width: 100%; + height: auto; + } +} + +.diagramLoading, +.diagramError, +.brokenImage { + color: var(--color-text-secondary); +} + +.diagramError { + white-space: pre-wrap; +} + +.imagePreview { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +.imageScroller { + width: 100%; + height: 100%; + overflow: auto; + background: + linear-gradient(45deg, #e8e8e8 25%, transparent 25%), + linear-gradient(-45deg, #e8e8e8 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e8e8e8 75%), + linear-gradient(-45deg, transparent 75%, #e8e8e8 75%); + background-position: + 0 0, + 0 8px, + 8px -8px, + -8px 0; + background-size: 16px 16px; +} + +.imageCanvas { + display: grid; + min-width: 100%; + min-height: 100%; + place-items: center; + + img { + display: block; + flex: none; + object-fit: contain; + } +} + +.imageToolbar { + position: absolute; + top: 8px; + right: 12px; + z-index: 3; + display: flex; + align-items: center; + padding: 2px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-base); + box-shadow: 0 2px 8px rgb(0 0 0 / 12%); + opacity: 0.3; + transition: opacity 160ms ease; + + &:hover, + &:focus-within { + opacity: 1; + } +} + +.imageZoomValue { + min-width: 52px; + padding-inline: 6px; + font-variant-numeric: tabular-nums; +} + +.pdfPreview { + position: relative; + width: 100%; + height: 100%; + background: var(--color-bg-base); +} + +.pdfObject { + display: block; + width: 100%; + height: 100%; + border: 0; +} + +.pdfStatus { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary); + background: var(--color-bg-base); + pointer-events: none; +} + +.previewFallback { + display: flex; + height: 100%; + align-items: center; + justify-content: center; + color: var(--color-text-secondary); +} + +@media (max-width: 760px) { + .markdown { + padding: 24px 20px 64px; + } +} diff --git a/chat2db-community-client/src/pages/main/workspace/components/WorkspaceTabs/FilePreviewTab.tsx b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceTabs/FilePreviewTab.tsx new file mode 100644 index 0000000000..475f372692 --- /dev/null +++ b/chat2db-community-client/src/pages/main/workspace/components/WorkspaceTabs/FilePreviewTab.tsx @@ -0,0 +1,609 @@ +import { memo, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; +import { Button, Segmented, Tooltip } from 'antd'; +import { staticMessage } from '@chat2db/ui'; +import { Code, Columns2, Eye } from 'lucide-react'; +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import ReactMarkdown, { type Components } from 'react-markdown'; +import rehypeKatex from 'rehype-katex'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import MonacoEditor, { type IEditorIns, type IExportRefFunction } from '@/components/MonacoEditor'; +import i18n from '@/i18n'; +import type { IBoundInfo } from '@/typings'; +import { updateFileContent } from '@/utils/file'; +import 'katex/dist/katex.min.css'; +import styles from './FilePreviewTab.less'; +import { getSqlDirectoryPreviewUrl } from '@/utils/previewResource'; + +interface FilePreviewTabProps { + file: IBoundInfo; + workspaceTabId: string | number; +} + +type MarkdownViewMode = 'source' | 'review' | 'split'; +type ScrollSyncOwner = 'source' | 'review' | null; +type PdfPreviewState = 'loading' | 'loaded' | 'failed'; + +const MIN_IMAGE_ZOOM = 0.25; +const MAX_IMAGE_ZOOM = 5; +const IMAGE_ZOOM_STEP = 0.25; +const PDF_LOAD_TIMEOUT_MS = 8000; +const MARKDOWN_PREVIEW_DELAY_MS = 200; +const MERMAID_CACHE_LIMIT = 50; + +let mermaidPromise: Promise<(typeof import('mermaid'))['default']> | undefined; +let mermaidRenderQueue: Promise = Promise.resolve(); +const mermaidSvgCache = new Map(); + +function getMermaid() { + if (!mermaidPromise) { + mermaidPromise = import('mermaid').then(({ default: mermaid }) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'neutral', + fontFamily: 'inherit', + }); + return mermaid; + }); + } + return mermaidPromise; +} + +function renderMermaid(source: string, diagramId: string, host?: HTMLDivElement) { + const cached = mermaidSvgCache.get(source); + if (cached) { + return Promise.resolve(cached.svg.replaceAll(cached.diagramId, diagramId)); + } + const renderPromise = mermaidRenderQueue + .catch(() => undefined) + .then(async () => { + const mermaid = await getMermaid(); + const parseResult = await mermaid.parse(source, { suppressErrors: true }); + if (!parseResult) { + return undefined; + } + const { svg } = await mermaid.render(diagramId, source, host); + mermaidSvgCache.set(source, { diagramId, svg }); + if (mermaidSvgCache.size > MERMAID_CACHE_LIMIT) { + mermaidSvgCache.delete(mermaidSvgCache.keys().next().value!); + } + return svg; + }); + mermaidRenderQueue = renderPromise; + return renderPromise; +} + +function MermaidDiagram({ source }: { source: string }) { + const reactId = useId(); + const diagramId = useMemo(() => `chat2db-mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`, [reactId]); + const renderHostRef = useRef(null); + const [svg, setSvg] = useState(''); + const [error, setError] = useState(false); + + useEffect(() => { + let active = true; + setSvg(''); + setError(false); + renderMermaid(source, diagramId, renderHostRef.current || undefined) + .then((renderedSvg) => { + if (active && renderedSvg) { + setSvg(renderedSvg); + } else if (active) { + setError(true); + } + }) + .catch((renderError) => { + console.error('render mermaid preview error', renderError); + if (active) { + setError(true); + } + }); + return () => { + active = false; + }; + }, [diagramId, source]); + + return ( + <> +