From b2819db495704bcb3228304e851525cee2698cf2 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 20 Jul 2026 20:22:35 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(shortcuts):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=BF=AB=E6=8D=B7=E9=94=AE=E5=85=B3=E9=97=AD=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E6=A0=87=E7=AD=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增可自定义的关闭当前标签页动作,macOS 默认 ⌘W,Windows/Linux 默认 Ctrl+W - 根据最近交互区域路由工作区或结果标签关闭,并隔离分离窗口 - 关闭日志标签时隐藏整个结果区,避免连续按键误关工作区 - 沿用未保存 SQL 与导入任务等工作区关闭保护 - 隔离 IME、快捷键录制、弹窗、抽屉与上下文菜单 - 补充多语言文案及快捷键路由回归测试 --- frontend/src/App.close-tab-shortcut.test.ts | 70 +++++ frontend/src/App.tsx | 84 +++++- .../DataGridLegacyCellContextMenu.tsx | 2 + frontend/src/components/DataGridShell.tsx | 2 + .../src/components/FloatingAIChatWindow.tsx | 2 + .../components/FloatingQueryResultWindows.tsx | 2 + .../components/FloatingWorkbenchWindows.tsx | 2 + .../QueryEditor.results-and-drop.test.tsx | 178 ++++++++++- frontend/src/components/QueryEditor.tsx | 86 ++++-- .../components/QueryEditorResultsPanel.tsx | 29 +- frontend/src/components/RedisViewer.tsx | 2 + frontend/src/components/Sidebar.tsx | 2 + .../src/components/TabManager.hover.test.tsx | 17 ++ frontend/src/components/TabManager.tsx | 25 +- frontend/src/components/TableOverview.tsx | 2 + .../components/closeTabShortcutGuards.test.ts | 29 ++ .../common/ResizableDraggableModal.tsx | 4 + .../components/resultDiff/ResultDiffPanel.tsx | 2 + frontend/src/i18n/catalog.test.ts | 2 + frontend/src/utils/closeTabShortcut.test.ts | 207 +++++++++++++ frontend/src/utils/closeTabShortcut.ts | 285 ++++++++++++++++++ frontend/src/utils/shortcuts.test.ts | 147 +++++++++ frontend/src/utils/shortcuts.ts | 58 +++- shared/i18n/de-DE.json | 2 + shared/i18n/en-US.json | 2 + shared/i18n/ja-JP.json | 2 + shared/i18n/ru-RU.json | 2 + shared/i18n/zh-CN.json | 2 + shared/i18n/zh-TW.json | 2 + 29 files changed, 1204 insertions(+), 47 deletions(-) create mode 100644 frontend/src/App.close-tab-shortcut.test.ts create mode 100644 frontend/src/components/closeTabShortcutGuards.test.ts create mode 100644 frontend/src/utils/closeTabShortcut.test.ts create mode 100644 frontend/src/utils/closeTabShortcut.ts diff --git a/frontend/src/App.close-tab-shortcut.test.ts b/frontend/src/App.close-tab-shortcut.test.ts new file mode 100644 index 00000000..32b336ca --- /dev/null +++ b/frontend/src/App.close-tab-shortcut.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const appSource = readFileSync(new URL('./App.tsx', import.meta.url), 'utf8'); +const modalSource = readFileSync( + new URL('./components/common/ResizableDraggableModal.tsx', import.meta.url), + 'utf8', +); +const floatingResultSource = readFileSync( + new URL('./components/FloatingQueryResultWindows.tsx', import.meta.url), + 'utf8', +); + +describe('App close-tab shortcut routing', () => { + it('tracks only explicit workspace, result, and blocked interaction scopes', () => { + expect(appSource).toContain("const closeShortcutScopeRef = useRef('workspace');"); + expect(appSource).toContain('resolveCloseShortcutScopeFromTarget(event.target)'); + expect(appSource).toContain("document.addEventListener('pointerdown', handleExplicitCloseShortcutScope, true);"); + expect(appSource).toContain("document.addEventListener('focusin', handleExplicitCloseShortcutScope, true);"); + expect(appSource).toContain('data-gonavi-close-shortcut-scope="workspace"'); + }); + + it('gives shortcut recording priority over every global action', () => { + const recorderGuardIndex = appSource.indexOf('if (capturingShortcutAction) {'); + const closeDecisionIndex = appSource.indexOf('const closeDecision = resolveCloseShortcutKeydownDecision({'); + expect(recorderGuardIndex).toBeGreaterThan(-1); + expect(closeDecisionIndex).toBeGreaterThan(recorderGuardIndex); + expect(appSource).toContain('setGlobalShortcutCaptureActive(Boolean(capturingShortcutAction));'); + }); + + it('uses a single close decision before dispatching exactly one scoped command', () => { + expect(appSource).toContain('interactionBlocked: isCloseShortcutInteractionBlocked(event.target, document)'); + expect(appSource).toContain("if (closeDecision.kind === 'consume') {"); + expect(appSource).toContain('event.stopImmediatePropagation();'); + expect(appSource).toContain("if (closeShortcutScopeRef.current === 'workspace') {"); + expect(appSource).toContain('dispatchCloseActiveWorkspaceTab();'); + expect(appSource).toContain("} else if (closeShortcutScopeRef.current === 'result') {"); + expect(appSource).toContain('const targetTabId = resolveDockedActiveTabId('); + expect(appSource).toContain('const outcome = dispatchCloseActiveResultTab(targetTabId);'); + }); + + it('enters blocked synchronously when the log tab hides the result area', () => { + const dispatchIndex = appSource.indexOf('const outcome = dispatchCloseActiveResultTab(targetTabId);'); + const hiddenIndex = appSource.indexOf("if (outcome === 'hidden') {", dispatchIndex); + const blockedIndex = appSource.indexOf("closeShortcutScopeRef.current = 'blocked';", hiddenIndex); + expect(dispatchIndex).toBeGreaterThan(-1); + expect(hiddenIndex).toBeGreaterThan(dispatchIndex); + expect(blockedIndex).toBeGreaterThan(hiddenIndex); + }); + + it('does not let the close router steal a migrated shortcut from its prior owner', () => { + expect(appSource).toContain("const delegatedAction = closeDecision.kind === 'delegate'"); + expect(appSource).toContain('if (delegatedAction && action !== delegatedAction) {'); + expect(appSource).toContain("if (action === 'closeActiveTab') {"); + }); +}); + +describe('close shortcut interaction guards', () => { + it('marks active reusable modals as background blockers', () => { + expect(modalSource).toContain("data-gonavi-close-shortcut-guard={active ? 'true' : undefined}"); + expect(modalSource).toContain("data-gonavi-close-shortcut-blocks-background={active ? 'true' : undefined}"); + expect(modalSource).toContain('data-gonavi-close-shortcut-blocks-background="true"'); + }); + + it('marks detached result windows as blocked without globally blocking their existence', () => { + expect(floatingResultSource).toContain('data-gonavi-close-shortcut-guard="true"'); + expect(floatingResultSource).toContain('data-gonavi-close-shortcut-scope="blocked"'); + expect(floatingResultSource).not.toContain('data-gonavi-close-shortcut-blocks-background="true"'); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d33c721..40d214d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -153,12 +153,23 @@ import { getShortcutPlatform, installGlobalImeCompositionTracking, isEditableElement, + isImeComposingKeyEvent, isShortcutMatch, normalizeShortcutCombo, resolveShortcutBinding, + setGlobalShortcutCaptureActive, splitConflictsByContext, type ConflictInfo, } from './utils/shortcuts'; +import { + dispatchCloseActiveResultTab, + dispatchCloseActiveWorkspaceTab, + isCloseShortcutInteractionBlocked, + resolveCloseShortcutKeydownDecision, + resolveCloseShortcutScopeFromTarget, + resolveDockedActiveTabId, + type CloseShortcutScope, +} from './utils/closeTabShortcut'; import { resolveTitleBarToggleIconKey, resolveWindowsScaleCheckDelayMs, shouldApplyWindowsScaleFix, shouldResetWebViewZoomForScaleFix, shouldToggleMaximisedWindowForScaleFix, type WindowScaleFixReason, type WindowsScaleCheckTrigger } from './utils/windowStateUi'; import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds'; import { resolveWailsWindowVisibleViewport } from './utils/wailsWindowViewport'; @@ -2870,9 +2881,14 @@ function App() { const [isLinuxCJKFontBannerDismissed, setIsLinuxCJKFontBannerDismissed] = useState(false); const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false); const [capturingShortcutAction, setCapturingShortcutAction] = useState(null); + const closeShortcutScopeRef = useRef('workspace'); const tabDisplaySettingsPanelRef = useRef(null); const [tabDisplaySettingsFocusRequest, setTabDisplaySettingsFocusRequest] = useState(0); const isThemeSettingsPaneOpen = activeSettingsCenterPane?.key === 'theme'; + useEffect(() => { + setGlobalShortcutCaptureActive(Boolean(capturingShortcutAction)); + return () => setGlobalShortcutCaptureActive(false); + }, [capturingShortcutAction]); useEffect(() => { const shouldLoadInstalledFonts = runtimePlatform === 'linux' || ((isThemeModalOpen || isThemeSettingsPaneOpen) && themeModalSection === 'appearance'); @@ -3867,9 +3883,73 @@ function App() { }; }, [isMacRuntime, useNativeMacWindowControls]); + useEffect(() => { + const handleExplicitCloseShortcutScope = (event: Event) => { + const nextScope = resolveCloseShortcutScopeFromTarget(event.target); + if (nextScope) { + closeShortcutScopeRef.current = nextScope; + } + }; + + document.addEventListener('pointerdown', handleExplicitCloseShortcutScope, true); + document.addEventListener('focusin', handleExplicitCloseShortcutScope, true); + return () => { + document.removeEventListener('pointerdown', handleExplicitCloseShortcutScope, true); + document.removeEventListener('focusin', handleExplicitCloseShortcutScope, true); + }; + }, []); + useEffect(() => { const handleGlobalShortcut = (event: KeyboardEvent) => { + // The recorder owns every key while it is active, including Cmd/Ctrl+W. + if (capturingShortcutAction) { + return; + } + + const closeDecision = resolveCloseShortcutKeydownDecision({ + event, + shortcutOptions, + platform: activeShortcutPlatform, + capturingShortcut: false, + imeComposing: isImeComposingKeyEvent(event), + interactionBlocked: isCloseShortcutInteractionBlocked(event.target, document), + }); + if (closeDecision.preventDefault) { + event.preventDefault(); + } + if (closeDecision.kind === 'consume') { + event.stopImmediatePropagation(); + return; + } + if (closeDecision.kind === 'close') { + event.stopImmediatePropagation(); + if (closeShortcutScopeRef.current === 'workspace') { + dispatchCloseActiveWorkspaceTab(); + } else if (closeShortcutScopeRef.current === 'result') { + const currentState = useStore.getState(); + const targetTabId = resolveDockedActiveTabId( + currentState.tabs, + currentState.activeTabId, + currentState.detachedWorkbenchWindows, + ); + const outcome = dispatchCloseActiveResultTab(targetTabId); + if (outcome === 'hidden') { + closeShortcutScopeRef.current = 'blocked'; + } + } + return; + } + + const delegatedAction = closeDecision.kind === 'delegate' + ? closeDecision.ownerAction + : null; const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => { + if (action === 'closeActiveTab') { + return false; + } + if (delegatedAction && action !== delegatedAction) { + return false; + } const meta = SHORTCUT_ACTION_META[action]; if (meta.scope && meta.scope !== 'global') { return false; @@ -3937,7 +4017,7 @@ function App() { return () => { window.removeEventListener('keydown', handleGlobalShortcut, true); }; - }, [activeShortcutPlatform, handleCreateConnection, handleManualResetWindowZoom, handleNewQuery, handleOpenToolCenterPane, handleTitleBarWindowToggle, handleToggleLogPanel, isMacRuntime, selectPresetTheme, shortcutOptions, switchActiveTabByOffset, themeMode, toggleAIPanel, useNativeMacWindowControls]); + }, [activeShortcutPlatform, capturingShortcutAction, handleCreateConnection, handleManualResetWindowZoom, handleNewQuery, handleOpenToolCenterPane, handleTitleBarWindowToggle, handleToggleLogPanel, isMacRuntime, selectPresetTheme, shortcutOptions, switchActiveTabByOffset, themeMode, toggleAIPanel, useNativeMacWindowControls]); useEffect(() => { if (!capturingShortcutAction) { @@ -6789,7 +6869,7 @@ function App() { contextKey={customThemeStyleContextKey} onAntTokensChange={setComputedCustomThemeAntTokens} /> - (
= ({
{
{
({ connections: [ @@ -2055,7 +2063,7 @@ describe('QueryEditor external SQL save', () => { expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3'); }); - it('renders V2 empty state copy for the active non-Chinese language', async () => { + it('renders the zero-count V2 SQL log tab for the active non-Chinese language', async () => { storeState.appearance.uiVersion = 'v2'; storeState.languagePreference = 'en-US'; setCurrentLanguage('en-US'); @@ -2066,10 +2074,15 @@ describe('QueryEditor external SQL save', () => { }); const rendered = textContent(renderer!.toJSON()); - expect(rendered).toContain('Awaiting SQL execution'); - expect(rendered).toContain('Run a query to display results below in the new data grid.'); - expect(rendered).not.toContain('等待执行 SQL'); - expect(rendered).not.toContain('运行查询后,结果会在下方以新版数据网格展示。'); + expect(rendered).toContain('Logs0'); + expect(renderer!.root.findAll((node) => node.props?.['data-log-panel'] === 'true')).toHaveLength(1); + expect(renderer!.root.findAll((node) => + node.props?.['data-tab-key'] === QUERY_EDITOR_SQL_LOG_TAB_KEY, + )).toHaveLength(1); + expect(rendered).not.toContain('日志0'); + await act(async () => { + renderer!.unmount(); + }); }); it('uses the last editor cursor position when the run button takes focus', async () => { @@ -2965,6 +2978,102 @@ describe('QueryEditor external SQL save', () => { expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })])); }); + it('closes the final result and synchronously hides the log tab on the next command', async () => { + storeState.appearance.uiVersion = 'v2'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['a'], rows: [{ a: 1 }] }], + }); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await act(async () => { + await findButton(renderer, '运行').props.onClick(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const closeRegistrations = (window.addEventListener as any).mock.calls + .filter(([eventName]: [string]) => eventName === CLOSE_ACTIVE_RESULT_TAB_EVENT); + expect(closeRegistrations).toHaveLength(1); + const closeListener = closeRegistrations[0][1] as EventListener; + (window.dispatchEvent as any).mockImplementation((event: Event) => { + closeListener(event); + return true; + }); + + let firstOutcome!: CloseActiveResultShortcutRequest; + let secondOutcome!: CloseActiveResultShortcutRequest; + act(() => { + const firstRequest: CloseActiveResultShortcutRequest = { targetTabId: 'tab-1', handled: false, outcome: 'ignored' }; + window.dispatchEvent(new CustomEvent(CLOSE_ACTIVE_RESULT_TAB_EVENT, { detail: firstRequest })); + firstOutcome = { ...firstRequest }; + + const secondRequest: CloseActiveResultShortcutRequest = { targetTabId: 'tab-1', handled: false, outcome: 'ignored' }; + window.dispatchEvent(new CustomEvent(CLOSE_ACTIVE_RESULT_TAB_EVENT, { detail: secondRequest })); + secondOutcome = { ...secondRequest }; + }); + + expect(firstOutcome).toEqual({ targetTabId: 'tab-1', handled: true, outcome: 'closed' }); + expect(secondOutcome).toEqual({ targetTabId: 'tab-1', handled: true, outcome: 'hidden' }); + expect(renderer.root.findAll((node) => + node.props?.['data-gonavi-close-shortcut-scope'] === 'result', + )).toHaveLength(0); + await act(async () => { + renderer.unmount(); + }); + }); + + it('ignores result close commands for hidden, invalid, or inactive result targets', async () => { + storeState.appearance.uiVersion = 'v2'; + let hiddenRenderer!: ReactTestRenderer; + await act(async () => { + hiddenRenderer = create(); + }); + + const closeRegistrations = (window.addEventListener as any).mock.calls + .filter(([eventName]: [string]) => eventName === CLOSE_ACTIVE_RESULT_TAB_EVENT); + expect(closeRegistrations).toHaveLength(1); + const hiddenRequest: CloseActiveResultShortcutRequest = { targetTabId: 'tab-1', handled: false, outcome: 'ignored' }; + closeRegistrations[0][1](new CustomEvent(CLOSE_ACTIVE_RESULT_TAB_EVENT, { detail: hiddenRequest })); + expect(hiddenRequest).toEqual({ targetTabId: 'tab-1', handled: true, outcome: 'ignored' }); + const detachedRequest: CloseActiveResultShortcutRequest = { targetTabId: 'detached-tab', handled: false, outcome: 'ignored' }; + closeRegistrations[0][1](new CustomEvent(CLOSE_ACTIVE_RESULT_TAB_EVENT, { detail: detachedRequest })); + expect(detachedRequest).toEqual({ targetTabId: 'detached-tab', handled: false, outcome: 'ignored' }); + await act(async () => { + hiddenRenderer.unmount(); + }); + + vi.mocked(window.addEventListener).mockClear(); + storeState.appearance.uiVersion = 'legacy'; + let invalidRenderer!: ReactTestRenderer; + await act(async () => { + invalidRenderer = create(); + }); + const invalidRegistrations = (window.addEventListener as any).mock.calls + .filter(([eventName]: [string]) => eventName === CLOSE_ACTIVE_RESULT_TAB_EVENT); + expect(invalidRegistrations).toHaveLength(1); + const invalidRequest: CloseActiveResultShortcutRequest = { targetTabId: 'tab-invalid', handled: false, outcome: 'ignored' }; + invalidRegistrations[0][1](new CustomEvent(CLOSE_ACTIVE_RESULT_TAB_EVENT, { detail: invalidRequest })); + expect(invalidRequest).toEqual({ targetTabId: 'tab-invalid', handled: true, outcome: 'ignored' }); + await act(async () => { + invalidRenderer.unmount(); + }); + + vi.mocked(window.addEventListener).mockClear(); + let inactiveRenderer!: ReactTestRenderer; + await act(async () => { + inactiveRenderer = create(); + }); + expect((window.addEventListener as any).mock.calls + .filter(([eventName]: [string]) => eventName === CLOSE_ACTIVE_RESULT_TAB_EVENT)).toHaveLength(0); + await act(async () => { + inactiveRenderer.unmount(); + }); + }); + it('replaces the current result when rerunning the same cursor SQL', async () => { backendApp.DBQueryMulti .mockResolvedValueOnce({ @@ -3188,7 +3297,8 @@ describe('QueryEditor external SQL save', () => { const editorSource = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); expect(panelSource).toContain('QUERY_EDITOR_SQL_LOG_TAB_KEY'); - expect(panelSource).toContain('const shouldShowSqlLogTab = isV2Ui && (sqlLogCount > 0 || activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY);'); + expect(panelSource).toContain('const shouldShowSqlLogTab = isV2Ui;'); + expect(panelSource).toContain('data-gonavi-close-shortcut-scope="result"'); expect(panelSource).toContain(' { }); it('does not render the embedded sql execution log tab in legacy UI', () => { - const renderResultsPanel = (isV2Ui: boolean) => create( + const renderResultsPanel = (isV2Ui: boolean, sqlLogCount = 1) => create( { expect(v2Renderer.root.findAll((node) => node.props?.['data-log-panel'] === 'true')).toHaveLength(1); expect(v2Renderer.root.findAll((node) => node.props?.['data-tab-key'] === '__gonavi_sql_execution_log__')).toHaveLength(1); v2Renderer.unmount(); + + const emptyV2Renderer = renderResultsPanel(true, 0); + expect(emptyV2Renderer.root.findAll((node) => node.props?.['data-log-panel'] === 'true')).toHaveLength(1); + expect(emptyV2Renderer.root.findAll((node) => node.props?.['data-tab-key'] === QUERY_EDITOR_SQL_LOG_TAB_KEY)).toHaveLength(1); + expect(emptyV2Renderer.root.findAll((node) => + node.props?.['data-gonavi-close-shortcut-scope'] === 'result', + )).toHaveLength(1); + emptyV2Renderer.unmount(); + }); + + it('uses the shared effective result key for stale-key rendering fallbacks', () => { + const resultSets = [{ + key: 'result-1', + sql: 'select 1 as value', + rows: [{ value: 1 }], + columns: ['value'], + pkColumns: [], + readOnly: true, + }]; + expect(resolveEffectiveActiveResultKey(resultSets, 'stale-result', true)).toBe('result-1'); + expect(resolveEffectiveActiveResultKey([], 'stale-result', true)).toBe(QUERY_EDITOR_SQL_LOG_TAB_KEY); + expect(resolveEffectiveActiveResultKey([], 'stale-result', false)).toBe(''); + + const renderer = create( + , + ); + expect(dataGridState.latestProps?.data).toEqual([{ value: 1 }]); + renderer.unmount(); }); it('keeps the v2 query editor toolbar grouped and compact', () => { diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index c941c957..1cb07fa9 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -37,6 +37,10 @@ import { isMacLikePlatform } from '../utils/appearance'; import { splitSidebarQualifiedName } from '../utils/sidebarLocate'; import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName } from '../utils/sidebarMetadata'; import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; +import { + CLOSE_ACTIVE_RESULT_TAB_EVENT, + type CloseActiveResultShortcutRequest, +} from '../utils/closeTabShortcut'; import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; import { t as translate } from '../i18n'; import { buildSqlAnalysisWorkbenchTab } from '../utils/sqlAnalysisTab'; @@ -75,6 +79,7 @@ import { } from '../utils/columnDefinition'; import QueryEditorResultsPanel, { QUERY_EDITOR_SQL_LOG_TAB_KEY, + resolveEffectiveActiveResultKey, type QueryEditorResultSet, } from './QueryEditorResultsPanel'; import ResultDiffWizard from './resultDiff/ResultDiffWizard'; @@ -1729,21 +1734,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc useEffect(() => { // Prefer remount session cache (detach/attach); otherwise follow tab draft flag. if (restoredResultSessionRef.current && restoredResultSessionRef.current.isResultPanelVisible !== undefined) { - setIsResultPanelVisible(restoredResultSessionRef.current.isResultPanelVisible === true); + const restoredVisible = restoredResultSessionRef.current.isResultPanelVisible === true; + isResultPanelVisibleRef.current = restoredVisible; + setIsResultPanelVisible(restoredVisible); return; } - setIsResultPanelVisible(tab.resultPanelVisible === true); + const restoredVisible = tab.resultPanelVisible === true; + isResultPanelVisibleRef.current = restoredVisible; + setIsResultPanelVisible(restoredVisible); }, [tab.id, tab.resultPanelVisible]); const updateResultPanelVisibility = useCallback((visible: boolean) => { + isResultPanelVisibleRef.current = visible; setIsResultPanelVisible(visible); updateQueryTabDraft(tab.id, { resultPanelVisible: visible }); }, [tab.id, updateQueryTabDraft]); const toggleResultPanelVisibility = useCallback(() => { - setIsResultPanelVisible((previousVisible) => { - const nextVisible = !previousVisible; - updateQueryTabDraft(tab.id, { resultPanelVisible: nextVisible }); - return nextVisible; - }); + const nextVisible = !isResultPanelVisibleRef.current; + isResultPanelVisibleRef.current = nextVisible; + setIsResultPanelVisible(nextVisible); + updateQueryTabDraft(tab.id, { resultPanelVisible: nextVisible }); }, [tab.id, updateQueryTabDraft]); const handleOpenEditorFind = useCallback(() => { const editor = editorRef.current; @@ -8457,20 +8466,63 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const handleCloseResult = (key: string) => { void cancelResultTotalCountRequests([key]); - setResultSets(prev => { - const idx = prev.findIndex(r => r.key === key); - if (idx < 0) return prev; - const next = prev.filter(r => r.key !== key); + const currentResultSets = resultSetsRef.current; + const idx = currentResultSets.findIndex(result => result.key === key); + if (idx < 0) return; - setActiveResultKey(prevActive => { - if (prevActive && prevActive !== key) return prevActive; - return next[idx]?.key || next[idx - 1]?.key || next[0]?.key || ''; - }); + const currentActiveKey = resolveEffectiveActiveResultKey( + currentResultSets, + activeResultKeyRef.current, + isV2Ui, + ); + const nextResultSets = currentResultSets.filter(result => result.key !== key); + const nextActiveKey = currentActiveKey && currentActiveKey !== key + ? currentActiveKey + : nextResultSets[idx]?.key + || nextResultSets[idx - 1]?.key + || nextResultSets[0]?.key + || (isV2Ui ? QUERY_EDITOR_SQL_LOG_TAB_KEY : ''); - return next; - }); + resultSetsRef.current = nextResultSets; + activeResultKeyRef.current = nextActiveKey; + setResultSets(nextResultSets); + setActiveResultKey(nextActiveKey); }; + useEffect(() => { + if (!isActive) return; + + const handleCloseActiveResultTab = (event: Event) => { + const request = (event as CustomEvent).detail; + if (!request || request.handled || request.targetTabId !== tab.id) return; + request.handled = true; + request.outcome = 'ignored'; + if (!isResultPanelVisibleRef.current) return; + + const effectiveActiveKey = resolveEffectiveActiveResultKey( + resultSetsRef.current, + activeResultKeyRef.current, + isV2Ui, + ); + if (!effectiveActiveKey) return; + + if (effectiveActiveKey === QUERY_EDITOR_SQL_LOG_TAB_KEY) { + updateResultPanelVisibility(false); + request.outcome = 'hidden'; + return; + } + if (!resultSetsRef.current.some(result => result.key === effectiveActiveKey)) return; + + handleCloseResult(effectiveActiveKey); + request.outcome = 'closed'; + }; + + window.addEventListener(CLOSE_ACTIVE_RESULT_TAB_EVENT, handleCloseActiveResultTab); + return () => { + window.removeEventListener(CLOSE_ACTIVE_RESULT_TAB_EVENT, handleCloseActiveResultTab); + }; + }, [isActive, isV2Ui, tab.id, updateResultPanelVisibility]); + const replaceResultSetsAfterMenuClose = (next: ResultSet[], preferredKey?: string) => { const nextKeys = new Set(next.map((result) => result.key)); const removedCountKeys = Object.keys(resultTotalCountRequestsRef.current) diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index 25f6964e..71595e14 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -54,6 +54,20 @@ export type QueryEditorResultSet = { page?: QueryResultPaginationState & { loading?: boolean }; }; +export const resolveEffectiveActiveResultKey = ( + resultSets: Pick[], + activeResultKey: string, + showSqlLogTab: boolean, +): string => { + if (resultSets.some((result) => result.key === activeResultKey)) { + return activeResultKey; + } + if (showSqlLogTab && activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY) { + return QUERY_EDITOR_SQL_LOG_TAB_KEY; + } + return resultSets[0]?.key || (showSqlLogTab ? QUERY_EDITOR_SQL_LOG_TAB_KEY : ''); +}; + interface QueryEditorResultsPanelProps { resultSets: QueryEditorResultSet[]; activeResultKey: string; @@ -295,17 +309,16 @@ const QueryEditorResultsPanel: React.FC = ({ window.addEventListener('pointercancel', handleUp); }, [onOpenResultInWindow, resolveResultTabTitle]); - const shouldShowSqlLogTab = isV2Ui && (sqlLogCount > 0 || activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY); + const shouldShowSqlLogTab = isV2Ui; const logTabCountLabel = sqlLogCount > 999 ? '999+' : String(sqlLogCount); const hideTooltipTitle = toggleShortcutLabel ? t('query_editor.results_panel.tooltip.hide_with_shortcut', { shortcut: toggleShortcutLabel }) : t('query_editor.results_panel.tooltip.hide'); - const activeResultKeyExists = activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY - ? shouldShowSqlLogTab - : resultSets.some((result) => result.key === activeResultKey); - const resolvedActiveResultKey = activeResultKeyExists - ? activeResultKey - : resultSets[0]?.key || (shouldShowSqlLogTab ? QUERY_EDITOR_SQL_LOG_TAB_KEY : ''); + const resolvedActiveResultKey = resolveEffectiveActiveResultKey( + resultSets, + activeResultKey, + shouldShowSqlLogTab, + ); const handleMessageTextareaKeyDown = (event: React.KeyboardEvent) => { if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') { @@ -643,7 +656,7 @@ const QueryEditorResultsPanel: React.FC = ({ .query-result-panel-hide { display: inline-flex; align-items: center; gap: 4px; } .query-result-panel-hide-compact { min-width: 28px; padding: 0 6px; justify-content: center; } `} -
+
{tabItems.length > 0 ? ( ) : executionError ? ( diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index ff83143f..e88e2b20 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -2521,6 +2521,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { {treeContextMenu && typeof document !== 'undefined' && createPortal((
{ expect(source).toContain('const TabManager: React.FC = React.memo(() => {'); }); + it('routes the workspace close command through the docked active tab close coordinator', () => { + const source = stripSourceComments(readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8')); + const handlerStart = source.indexOf('const requestCloseActiveWorkspaceTab = useCallback(() => {'); + const handlerEnd = source.indexOf('\n useEffect(() => {', handlerStart); + const handlerSource = source.slice(handlerStart, handlerEnd); + + expect(handlerStart).toBeGreaterThan(-1); + expect(handlerEnd).toBeGreaterThan(handlerStart); + expect(source).toContain("import { CLOSE_ACTIVE_WORKSPACE_TAB_EVENT, resolveDockedActiveTabId } from '../utils/closeTabShortcut';"); + expect(handlerSource).toContain('if (!dockedActiveTabId) return;'); + expect(handlerSource).toContain('closeTabsWithSQLFilePrompt(\n [dockedActiveTabId],\n () => closeTab(dockedActiveTabId),'); + expect(handlerSource).not.toContain('[activeTabId]'); + expect(source).toContain('window.addEventListener(CLOSE_ACTIVE_WORKSPACE_TAB_EVENT, requestCloseActiveWorkspaceTab);'); + expect(source).toContain('window.removeEventListener(CLOSE_ACTIVE_WORKSPACE_TAB_EVENT, requestCloseActiveWorkspaceTab);'); + expect(source).not.toContain("window.addEventListener('keydown'"); + }); + it('keeps the tab workbench as a full-height flex child in legacy and v2 UI', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 198a8338..004907ff 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -1,5 +1,5 @@ import Modal from './common/ResizableDraggableModal'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button, Dropdown, message, Tabs, Tooltip } from 'antd'; import { CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, FileTextOutlined, FolderOpenOutlined, HistoryOutlined, PlusOutlined, PushpinOutlined, RightOutlined, RobotOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons'; import type { MenuProps, TabsProps } from 'antd'; @@ -29,6 +29,7 @@ import { clearSQLFileTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDra import { buildExternalSQLTabId } from '../utils/externalSqlTree'; import { buildSQLFileExecutionWorkbenchTab } from '../utils/sqlFileExecutionTab'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; +import { CLOSE_ACTIVE_WORKSPACE_TAB_EVENT, resolveDockedActiveTabId } from '../utils/closeTabShortcut'; import WorkbenchTabContent from './WorkbenchTabContent'; import DetachDragPreview, { buildDetachDragPreviewState, @@ -655,11 +656,8 @@ const TabManager: React.FC = React.memo(() => { }); }, [tabs]); const dockedActiveTabId = useMemo(() => { - if (activeTabId && dockedTabs.some((tab) => tab.id === activeTabId)) { - return activeTabId; - } - return dockedTabs[0]?.id || null; - }, [activeTabId, dockedTabs]); + return resolveDockedActiveTabId(tabs, activeTabId, detachedWorkbenchWindows); + }, [activeTabId, detachedWorkbenchWindows, tabs]); const pendingCloseTabIdsRef = useRef>(new Set()); const onChange = (newActiveKey: string) => { @@ -811,6 +809,21 @@ const TabManager: React.FC = React.memo(() => { }); }, [requestCloseSQLFileTabs, tabs]); + const requestCloseActiveWorkspaceTab = useCallback(() => { + if (!dockedActiveTabId) return; + closeTabsWithSQLFilePrompt( + [dockedActiveTabId], + () => closeTab(dockedActiveTabId), + ); + }, [closeTab, closeTabsWithSQLFilePrompt, dockedActiveTabId]); + + useEffect(() => { + window.addEventListener(CLOSE_ACTIVE_WORKSPACE_TAB_EVENT, requestCloseActiveWorkspaceTab); + return () => { + window.removeEventListener(CLOSE_ACTIVE_WORKSPACE_TAB_EVENT, requestCloseActiveWorkspaceTab); + }; + }, [requestCloseActiveWorkspaceTab]); + const onEdit = (targetKey: React.MouseEvent | React.KeyboardEvent | string, action: 'add' | 'remove') => { if (action === 'remove') { const id = String(targetKey || ''); diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index f627f75f..a9013f1e 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -1387,6 +1387,8 @@ const TableOverview: React.FC = ({ tab }) => {
readFileSync(new URL(path, import.meta.url), 'utf8'); + +describe('close-tab shortcut portal guards', () => { + it.each([ + './DataGridLegacyCellContextMenu.tsx', + './DataGridShell.tsx', + './Sidebar.tsx', + './TableOverview.tsx', + './RedisViewer.tsx', + ])('blocks background close commands while the interactive portal is visible: %s', (path) => { + const source = readComponent(path); + expect(source).toContain('data-gonavi-close-shortcut-guard="true"'); + expect(source).toContain('data-gonavi-close-shortcut-blocks-background="true"'); + }); + + it.each([ + './FloatingQueryResultWindows.tsx', + './FloatingWorkbenchWindows.tsx', + './FloatingAIChatWindow.tsx', + './resultDiff/ResultDiffPanel.tsx', + ])('blocks routing after explicit detached-window interaction without global blocking: %s', (path) => { + const source = readComponent(path); + expect(source).toContain('data-gonavi-close-shortcut-guard="true"'); + expect(source).toContain('data-gonavi-close-shortcut-scope="blocked"'); + }); +}); diff --git a/frontend/src/components/common/ResizableDraggableModal.tsx b/frontend/src/components/common/ResizableDraggableModal.tsx index 6549ee1c..d5ef9a09 100644 --- a/frontend/src/components/common/ResizableDraggableModal.tsx +++ b/frontend/src/components/common/ResizableDraggableModal.tsx @@ -251,6 +251,8 @@ const DraggableResizableModalFrame: React.FC data-resizing={isResizing ? 'true' : 'false'} data-has-resized-width={size.width ? 'true' : 'false'} data-has-resized-height={size.height ? 'true' : 'false'} + data-gonavi-close-shortcut-guard={active ? 'true' : undefined} + data-gonavi-close-shortcut-blocks-background={active ? 'true' : undefined} style={frameStyle} > {children} @@ -299,6 +301,8 @@ const ResizableDraggableModalBase: React.FC = ({ props.rootClassName, props.className, ].filter(Boolean).join(' ')} + data-gonavi-close-shortcut-guard="true" + data-gonavi-close-shortcut-blocks-background="true" style={props.style} > {props.title || props.closable !== false ? ( diff --git a/frontend/src/components/resultDiff/ResultDiffPanel.tsx b/frontend/src/components/resultDiff/ResultDiffPanel.tsx index a8c66910..3cabd054 100644 --- a/frontend/src/components/resultDiff/ResultDiffPanel.tsx +++ b/frontend/src/components/resultDiff/ResultDiffPanel.tsx @@ -779,6 +779,8 @@ const ResultDiffPanel: React.FC = ({ `}
{ it("includes App shortcut modal keys required by every supported language", () => { const shortcutModalKeys = [ + "app.shortcuts.action.closeActiveTab.description", + "app.shortcuts.action.closeActiveTab.label", "app.shortcuts.action.focusSidebarSearch.description", "app.shortcuts.action.focusSidebarSearch.label", "app.shortcuts.action.newConnection.description", diff --git a/frontend/src/utils/closeTabShortcut.test.ts b/frontend/src/utils/closeTabShortcut.test.ts new file mode 100644 index 00000000..c9f49de6 --- /dev/null +++ b/frontend/src/utils/closeTabShortcut.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_SHORTCUT_OPTIONS, cloneShortcutOptions } from './shortcuts'; +import { + CLOSE_SHORTCUT_BACKGROUND_BLOCKER_SELECTOR, + dispatchCloseActiveResultTab, + getPlatformNativeCloseCombo, + hasVisibleCloseShortcutBackgroundBlocker, + resolveCloseShortcutKeydownDecision, + resolveCloseShortcutScopeFromTarget, + resolveDockedActiveTabId, +} from './closeTabShortcut'; + +const keyEvent = (overrides: Partial<{ + key: string; + code: string; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + shiftKey: boolean; + isComposing: boolean; + keyCode: number; +}> = {}) => ({ + key: 'w', + code: 'KeyW', + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + keyCode: 87, + ...overrides, +}); + +describe('close tab shortcut routing decision', () => { + it('routes the enabled platform default to closeActiveTab', () => { + expect(resolveCloseShortcutKeydownDecision({ + event: keyEvent({ metaKey: true }), + shortcutOptions: DEFAULT_SHORTCUT_OPTIONS, + platform: 'mac', + capturingShortcut: false, + imeComposing: false, + interactionBlocked: false, + })).toEqual({ + kind: 'close', + preventDefault: true, + stopImmediatePropagation: true, + ownerAction: 'closeActiveTab', + }); + }); + + it('consumes the native close combo when the action is disabled', () => { + const options = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS); + options.closeActiveTab.windows.enabled = false; + expect(resolveCloseShortcutKeydownDecision({ + event: keyEvent({ ctrlKey: true }), + shortcutOptions: options, + platform: 'windows', + capturingShortcut: false, + imeComposing: false, + interactionBlocked: false, + })).toMatchObject({ kind: 'consume', ownerAction: null, preventDefault: true }); + }); + + it('delegates a migrated native combo to its existing action owner', () => { + const options = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS); + options.closeActiveTab.windows.enabled = false; + options.newQueryTab.windows.combo = 'Ctrl+W'; + expect(resolveCloseShortcutKeydownDecision({ + event: keyEvent({ ctrlKey: true }), + shortcutOptions: options, + platform: 'windows', + capturingShortcut: false, + imeComposing: false, + interactionBlocked: false, + })).toEqual({ + kind: 'delegate', + preventDefault: true, + stopImmediatePropagation: false, + ownerAction: 'newQueryTab', + }); + }); + + it('consumes without dispatch during IME or guarded interactions', () => { + for (const flags of [ + { imeComposing: true, interactionBlocked: false }, + { imeComposing: false, interactionBlocked: true }, + ]) { + expect(resolveCloseShortcutKeydownDecision({ + event: keyEvent({ metaKey: true, isComposing: flags.imeComposing }), + shortcutOptions: DEFAULT_SHORTCUT_OPTIONS, + platform: 'mac', + capturingShortcut: false, + ...flags, + })).toMatchObject({ kind: 'consume', preventDefault: true, stopImmediatePropagation: true }); + } + }); + + it('lets the recorder own the event before close routing', () => { + expect(resolveCloseShortcutKeydownDecision({ + event: keyEvent({ metaKey: true }), + shortcutOptions: DEFAULT_SHORTCUT_OPTIONS, + platform: 'mac', + capturingShortcut: true, + imeComposing: false, + interactionBlocked: false, + })).toEqual({ kind: 'recording', preventDefault: false, stopImmediatePropagation: false }); + }); + + it('ignores unrelated combinations', () => { + expect(resolveCloseShortcutKeydownDecision({ + event: keyEvent({ key: 'q', code: 'KeyQ', metaKey: true }), + shortcutOptions: DEFAULT_SHORTCUT_OPTIONS, + platform: 'mac', + capturingShortcut: false, + imeComposing: false, + interactionBlocked: false, + })).toEqual({ kind: 'ignore', preventDefault: false, stopImmediatePropagation: false }); + }); + + it('maps native close keys per platform', () => { + expect(getPlatformNativeCloseCombo('mac')).toBe('Meta+W'); + expect(getPlatformNativeCloseCombo('windows')).toBe('Ctrl+W'); + }); +}); + +describe('close shortcut interaction scope', () => { + const target = (matches: Record) => ({ + closest: vi.fn((selector: string) => { + const match = matches[selector]; + if (!match) return null; + return { + getAttribute: (name: string) => name === 'data-gonavi-close-shortcut-scope' + ? match.scope ?? null + : null, + }; + }), + }); + + it('prefers detached blocked ownership over a background workspace', () => { + const node = target({ + '[data-gonavi-close-shortcut-scope="blocked"], .gn-detached-result-window, .gn-detached-window, .gn-detached-ai-chat-window, .gn-result-diff-floating-window': {}, + }); + expect(resolveCloseShortcutScopeFromTarget(node)).toBe('blocked'); + }); + + it('returns the explicit result or workspace scope', () => { + const result = target({ + '[data-gonavi-close-shortcut-scope]': { scope: 'result' }, + }); + const workspace = target({ + '[data-gonavi-close-shortcut-scope]': { scope: 'workspace' }, + }); + expect(resolveCloseShortcutScopeFromTarget(result)).toBe('result'); + expect(resolveCloseShortcutScopeFromTarget(workspace)).toBe('workspace'); + }); + + it('does not let an ordinary guard change the remembered scope', () => { + const guarded = { + closest: vi.fn((selector: string) => selector.includes('data-gonavi-close-shortcut-guard') ? {} : null), + }; + expect(resolveCloseShortcutScopeFromTarget(guarded)).toBeNull(); + }); + + it('detects only visible background blockers', () => { + const visible = { + hidden: false, + style: {}, + getAttribute: () => null, + classList: { contains: () => false }, + ownerDocument: { defaultView: { getComputedStyle: () => ({ display: 'block', visibility: 'visible' }) } }, + }; + const hidden = { + ...visible, + style: { display: 'none' }, + }; + const documentTarget = { + querySelectorAll: vi.fn(() => [hidden, visible]), + }; + expect(hasVisibleCloseShortcutBackgroundBlocker(documentTarget)).toBe(true); + expect(documentTarget.querySelectorAll).toHaveBeenCalledWith(CLOSE_SHORTCUT_BACKGROUND_BLOCKER_SELECTOR); + }); +}); + +describe('result close command', () => { + it('resolves the visible docked tab independently from detached activity', () => { + const tabs = [{ id: 'docked-1' }, { id: 'detached-1' }, { id: 'docked-2' }]; + const detached = [{ tabId: 'detached-1' }]; + expect(resolveDockedActiveTabId(tabs, 'docked-2', detached)).toBe('docked-2'); + expect(resolveDockedActiveTabId(tabs, 'detached-1', detached)).toBe('docked-1'); + expect(resolveDockedActiveTabId([{ id: 'detached-1' }], 'detached-1', detached)).toBeNull(); + }); + + it('returns the synchronously mutated request outcome', () => { + const eventTarget = { + dispatchEvent: vi.fn((event: CustomEvent) => { + event.detail.handled = true; + event.detail.outcome = 'hidden'; + return true; + }), + }; + expect(dispatchCloseActiveResultTab('tab-1', eventTarget as unknown as Window)).toBe('hidden'); + expect(eventTarget.dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({ + detail: expect.objectContaining({ targetTabId: 'tab-1' }), + })); + }); +}); diff --git a/frontend/src/utils/closeTabShortcut.ts b/frontend/src/utils/closeTabShortcut.ts new file mode 100644 index 00000000..132536ae --- /dev/null +++ b/frontend/src/utils/closeTabShortcut.ts @@ -0,0 +1,285 @@ +import { + SHORTCUT_ACTION_ORDER, + isShortcutPhysicalMatch, + resolveShortcutBinding, + type ShortcutAction, + type ShortcutOptions, + type ShortcutPlatform, +} from './shortcuts'; + +export const CLOSE_ACTIVE_WORKSPACE_TAB_EVENT = 'gonavi:close-active-workspace-tab'; +export const CLOSE_ACTIVE_RESULT_TAB_EVENT = 'gonavi:close-active-result-tab'; + +export type CloseShortcutScope = 'workspace' | 'result' | 'blocked'; +export type CloseActiveResultShortcutOutcome = 'closed' | 'hidden' | 'ignored'; + +export interface CloseActiveResultShortcutRequest { + targetTabId: string | null; + handled: boolean; + outcome: CloseActiveResultShortcutOutcome; +} + +export const resolveDockedActiveTabId = ( + tabs: Array<{ id: string }>, + activeTabId: string | null | undefined, + detachedWindows: Array<{ tabId: string }>, +): string | null => { + const detachedTabIds = new Set(detachedWindows.map((windowState) => windowState.tabId)); + const dockedTabs = tabs.filter((tab) => !detachedTabIds.has(tab.id)); + if (activeTabId && dockedTabs.some((tab) => tab.id === activeTabId)) { + return activeTabId; + } + return dockedTabs[0]?.id ?? null; +}; + +export interface CloseShortcutKeyEvent { + key: string; + code?: string; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + shiftKey: boolean; + isComposing?: boolean; + keyCode?: number; + which?: number; +} + +export type CloseShortcutKeydownDecision = + | { kind: 'ignore' | 'recording'; preventDefault: false; stopImmediatePropagation: false } + | { kind: 'consume'; preventDefault: true; stopImmediatePropagation: true; ownerAction: ShortcutAction | null } + | { kind: 'close'; preventDefault: true; stopImmediatePropagation: true; ownerAction: 'closeActiveTab' } + | { kind: 'delegate'; preventDefault: true; stopImmediatePropagation: false; ownerAction: ShortcutAction }; + +export const getPlatformNativeCloseCombo = (platform: ShortcutPlatform): string => ( + platform === 'mac' ? 'Meta+W' : 'Ctrl+W' +); + +const resolvePhysicalShortcutOwner = ( + event: CloseShortcutKeyEvent, + shortcutOptions: Partial | null | undefined, + platform: ShortcutPlatform, +): ShortcutAction | null => ( + SHORTCUT_ACTION_ORDER.find((action) => { + const binding = resolveShortcutBinding(shortcutOptions, action, platform); + return binding.enabled && isShortcutPhysicalMatch(event as KeyboardEvent, binding.combo); + }) ?? null +); + +export const resolveCloseShortcutKeydownDecision = ({ + event, + shortcutOptions, + platform, + capturingShortcut, + imeComposing, + interactionBlocked, +}: { + event: CloseShortcutKeyEvent; + shortcutOptions: Partial | null | undefined; + platform: ShortcutPlatform; + capturingShortcut: boolean; + imeComposing: boolean; + interactionBlocked: boolean; +}): CloseShortcutKeydownDecision => { + const nativeCloseMatched = isShortcutPhysicalMatch( + event as KeyboardEvent, + getPlatformNativeCloseCombo(platform), + ); + const closeBinding = resolveShortcutBinding(shortcutOptions, 'closeActiveTab', platform); + const configuredCloseMatched = closeBinding.enabled + && isShortcutPhysicalMatch(event as KeyboardEvent, closeBinding.combo); + + if (!nativeCloseMatched && !configuredCloseMatched) { + return { kind: 'ignore', preventDefault: false, stopImmediatePropagation: false }; + } + if (capturingShortcut) { + return { kind: 'recording', preventDefault: false, stopImmediatePropagation: false }; + } + + const ownerAction = resolvePhysicalShortcutOwner(event, shortcutOptions, platform); + if (imeComposing || interactionBlocked) { + return { + kind: 'consume', + preventDefault: true, + stopImmediatePropagation: true, + ownerAction, + }; + } + if (ownerAction === 'closeActiveTab') { + return { + kind: 'close', + preventDefault: true, + stopImmediatePropagation: true, + ownerAction, + }; + } + if (ownerAction) { + return { + kind: 'delegate', + preventDefault: true, + stopImmediatePropagation: false, + ownerAction, + }; + } + + return { + kind: 'consume', + preventDefault: true, + stopImmediatePropagation: true, + ownerAction: null, + }; +}; + +type ClosestTarget = { + closest?: (selector: string) => ClosestTarget | null; + parentElement?: ClosestTarget | null; + getAttribute?: (name: string) => string | null; + hidden?: boolean; + style?: { + display?: string; + visibility?: string; + }; + classList?: { + contains?: (name: string) => boolean; + }; + ownerDocument?: { + defaultView?: { + getComputedStyle?: (element: unknown) => { + display?: string; + visibility?: string; + }; + } | null; + } | null; +}; + +type QueryDocument = { + querySelectorAll?: (selector: string) => ArrayLike; +}; + +const CLOSE_SHORTCUT_SCOPE_SELECTOR = '[data-gonavi-close-shortcut-scope]'; + +const DETACHED_CLOSE_SHORTCUT_SCOPE_SELECTOR = [ + '[data-gonavi-close-shortcut-scope="blocked"]', + '.gn-detached-result-window', + '.gn-detached-window', + '.gn-detached-ai-chat-window', + '.gn-result-diff-floating-window', +].join(', '); + +export const CLOSE_SHORTCUT_GUARD_SELECTOR = [ + '[data-gonavi-close-shortcut-guard="true"]', + '.ant-modal-wrap', + '.ant-drawer', + '.ant-dropdown', + '.ant-select-dropdown', + '.ant-picker-dropdown', + '.ant-popover', + '.gn-v2-table-context-menu-portal', + '.gn-v2-sidebar-context-menu-portal', + '.gn-v2-table-overview-context-menu-portal', + '.gn-v2-redis-context-menu', + '.gn-v2-context-menu', +].join(', '); + +export const CLOSE_SHORTCUT_BACKGROUND_BLOCKER_SELECTOR = [ + '[data-gonavi-close-shortcut-blocks-background="true"]', + '.ant-modal-wrap', + '.ant-drawer.ant-drawer-open', + '.ant-dropdown:not(.ant-dropdown-hidden)', + '.ant-select-dropdown:not(.ant-select-dropdown-hidden)', + '.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)', + '.ant-popover:not(.ant-popover-hidden)', + '.gn-v2-table-context-menu-portal', + '.gn-v2-sidebar-context-menu-portal', + '.gn-v2-table-overview-context-menu-portal', + '.gn-v2-redis-context-menu', + '.gn-v2-context-menu', +].join(', '); + +const asClosestTarget = (target: EventTarget | ClosestTarget | null | undefined): ClosestTarget | null => { + if (!target || typeof target !== 'object') return null; + const candidate = target as ClosestTarget; + if (typeof candidate.closest === 'function') return candidate; + return candidate.parentElement && typeof candidate.parentElement.closest === 'function' + ? candidate.parentElement + : null; +}; + +const closest = (target: ClosestTarget | null, selector: string): ClosestTarget | null => { + if (!target || typeof target.closest !== 'function') return null; + return target.closest(selector); +}; + +export const resolveCloseShortcutScopeFromTarget = ( + target: EventTarget | ClosestTarget | null | undefined, +): CloseShortcutScope | null => { + const element = asClosestTarget(target); + if (!element) return null; + + if (closest(element, DETACHED_CLOSE_SHORTCUT_SCOPE_SELECTOR)) { + return 'blocked'; + } + if (closest(element, CLOSE_SHORTCUT_GUARD_SELECTOR)) { + return null; + } + + const scopeElement = closest(element, CLOSE_SHORTCUT_SCOPE_SELECTOR); + const scope = scopeElement?.getAttribute?.('data-gonavi-close-shortcut-scope'); + return scope === 'workspace' || scope === 'result' || scope === 'blocked' + ? scope + : null; +}; + +export const isCloseShortcutGuardTarget = ( + target: EventTarget | ClosestTarget | null | undefined, +): boolean => Boolean(closest(asClosestTarget(target), CLOSE_SHORTCUT_GUARD_SELECTOR)); + +const isVisibleBlocker = (element: ClosestTarget): boolean => { + if (element.hidden || element.getAttribute?.('aria-hidden') === 'true') return false; + if ( + element.classList?.contains?.('ant-dropdown-hidden') + || element.classList?.contains?.('ant-select-dropdown-hidden') + || element.classList?.contains?.('ant-picker-dropdown-hidden') + || element.classList?.contains?.('ant-popover-hidden') + ) { + return false; + } + if (element.style?.display === 'none' || element.style?.visibility === 'hidden') return false; + const computedStyle = element.ownerDocument?.defaultView?.getComputedStyle?.(element); + return computedStyle?.display !== 'none' && computedStyle?.visibility !== 'hidden'; +}; + +export const hasVisibleCloseShortcutBackgroundBlocker = ( + documentTarget: QueryDocument | null | undefined, +): boolean => { + const elements = documentTarget?.querySelectorAll?.(CLOSE_SHORTCUT_BACKGROUND_BLOCKER_SELECTOR); + if (!elements) return false; + return Array.from(elements).some(isVisibleBlocker); +}; + +export const isCloseShortcutInteractionBlocked = ( + target: EventTarget | ClosestTarget | null | undefined, + documentTarget: QueryDocument | null | undefined, +): boolean => ( + isCloseShortcutGuardTarget(target) + || hasVisibleCloseShortcutBackgroundBlocker(documentTarget) +); + +export const dispatchCloseActiveWorkspaceTab = (eventTarget: Window = window): void => { + eventTarget.dispatchEvent(new CustomEvent(CLOSE_ACTIVE_WORKSPACE_TAB_EVENT)); +}; + +export const dispatchCloseActiveResultTab = ( + targetTabId: string | null, + eventTarget: Window = window, +): CloseActiveResultShortcutOutcome => { + const request: CloseActiveResultShortcutRequest = { + targetTabId, + handled: false, + outcome: 'ignored', + }; + eventTarget.dispatchEvent(new CustomEvent( + CLOSE_ACTIVE_RESULT_TAB_EVENT, + { detail: request }, + )); + return request.outcome; +}; diff --git a/frontend/src/utils/shortcuts.test.ts b/frontend/src/utils/shortcuts.test.ts index cd6d12f7..e9aa788b 100644 --- a/frontend/src/utils/shortcuts.test.ts +++ b/frontend/src/utils/shortcuts.test.ts @@ -18,10 +18,13 @@ import { getShortcutPrimaryModifierDisplayLabel, installGlobalImeCompositionTracking, isGlobalImeCompositionActive, + isGlobalShortcutCaptureActive, isImeComposingKeyEvent, isShortcutMatch, + isShortcutPhysicalMatch, resolveShortcutBinding, resolveShortcutDisplay, + setGlobalShortcutCaptureActive, setGlobalImeCompositionActive, sanitizeShortcutOptions, SHORTCUT_ACTION_META, @@ -31,6 +34,7 @@ import type { ConflictInfo } from './shortcuts'; beforeEach(() => { setCurrentLanguage('zh-CN'); setGlobalImeCompositionActive(false); + setGlobalShortcutCaptureActive(false); }); // ─── findReservedConflict ──────────────────────────────────────────── @@ -93,6 +97,10 @@ describe('findReservedConflicts', () => { expect(findReservedConflicts('Ctrl+Shift+Q')).toEqual([]); }); + it('does not reserve Ctrl+W after the app takes ownership of close-tab', () => { + expect(findReservedConflicts('Ctrl+W')).toEqual([]); + }); + it('preserves monacoCommandId in results', () => { const results = findReservedConflicts('Ctrl+F'); expect(results[0].monacoCommandId).toBe('actions.find'); @@ -199,6 +207,49 @@ describe('RESERVED_SHORTCUTS', () => { }); describe('IME shortcut guards', () => { + it('suppresses normal shortcut owners while the recorder is active', () => { + const event = { + key: 'w', + code: 'KeyW', + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + } as KeyboardEvent; + + setGlobalShortcutCaptureActive(true); + expect(isGlobalShortcutCaptureActive()).toBe(true); + expect(isShortcutMatch(event, 'Ctrl+W')).toBe(false); + expect(isShortcutPhysicalMatch(event, 'Ctrl+W')).toBe(true); + }); + + it('keeps a recorder registered after an existing owner safe from listener order', () => { + const target = new EventTarget(); + const owner = vi.fn(); + const recorder = vi.fn(); + target.addEventListener('keydown', (rawEvent) => { + if (isShortcutMatch(rawEvent as KeyboardEvent, 'Ctrl+W')) owner(); + }); + + setGlobalShortcutCaptureActive(true); + target.addEventListener('keydown', (rawEvent) => { + recorder(eventToShortcut(rawEvent as KeyboardEvent)); + }); + const event = new Event('keydown', { cancelable: true }); + Object.defineProperties(event, { + key: { value: 'w' }, + code: { value: 'KeyW' }, + ctrlKey: { value: true }, + metaKey: { value: false }, + altKey: { value: false }, + shiftKey: { value: false }, + }); + target.dispatchEvent(event); + + expect(owner).not.toHaveBeenCalled(); + expect(recorder).toHaveBeenCalledWith('Ctrl+W'); + }); + it('tracks composition state through global listeners', () => { const windowListeners = new Map(); const documentListeners = new Map(); @@ -267,6 +318,28 @@ describe('IME shortcut guards', () => { expect(isShortcutMatch(event, 'Ctrl+Enter')).toBe(false); }); + it('matches a physical shortcut during IME composition without changing the guarded matcher', () => { + const event = { + key: 'w', + code: 'KeyW', + keyCode: 229, + which: 229, + isComposing: true, + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + nativeEvent: { + isComposing: true, + keyCode: 229, + which: 229, + }, + } as unknown as KeyboardEvent; + + expect(isShortcutPhysicalMatch(event, 'Ctrl+W')).toBe(true); + expect(isShortcutMatch(event, 'Ctrl+W')).toBe(false); + }); + it('matches modifier shortcuts from KeyboardEvent.code when WebView reports Process', () => { const event = { key: 'Process', @@ -368,6 +441,18 @@ describe('IME shortcut guards', () => { // ─── shortcut defaults ─────────────────────────────────────────────── describe('shortcut defaults', () => { + it('registers close active tab as an editable global shortcut', () => { + expect(DEFAULT_SHORTCUT_OPTIONS.closeActiveTab).toEqual({ + mac: { combo: 'Meta+W', enabled: true }, + windows: { combo: 'Ctrl+W', enabled: true }, + }); + expect(SHORTCUT_ACTION_META.closeActiveTab).toMatchObject({ + label: '关闭当前标签页', + scope: 'global', + allowInEditable: true, + }); + }); + it('registers select current statement as a query editor shortcut', () => { expect(DEFAULT_SHORTCUT_OPTIONS.selectCurrentStatement).toEqual({ mac: { combo: 'Meta+E', enabled: true }, @@ -518,6 +603,68 @@ describe('shortcut defaults', () => { windows: { combo: 'Ctrl+Shift+R', enabled: false }, }); expect(options.newQueryTab.windows.combo).toBe('Ctrl+N'); + expect(options.closeActiveTab).toEqual({ + mac: { combo: 'Meta+W', enabled: true }, + windows: { combo: 'Ctrl+W', enabled: true }, + }); + }); + + it('keeps close active tab enabled for new and empty shortcut settings', () => { + expect(sanitizeShortcutOptions(undefined).closeActiveTab).toEqual(DEFAULT_SHORTCUT_OPTIONS.closeActiveTab); + expect(sanitizeShortcutOptions({}).closeActiveTab).toEqual(DEFAULT_SHORTCUT_OPTIONS.closeActiveTab); + }); + + it('disables only the conflicting close-tab platform while preserving current platform bindings', () => { + const options = sanitizeShortcutOptions({ + saveQuery: { + mac: { combo: 'Meta+W', enabled: true }, + windows: { combo: 'Ctrl+S', enabled: true }, + }, + toggleTheme: { + mac: { combo: 'Meta+Shift+D', enabled: true }, + windows: { combo: 'Ctrl+W', enabled: true }, + }, + }); + + expect(options.saveQuery.mac).toEqual({ combo: 'Meta+W', enabled: true }); + expect(options.toggleTheme.windows).toEqual({ combo: 'Ctrl+W', enabled: true }); + expect(options.closeActiveTab).toEqual({ + mac: { combo: 'Meta+W', enabled: false }, + windows: { combo: 'Ctrl+W', enabled: false }, + }); + }); + + it('migrates legacy single-platform close-tab conflicts independently per platform', () => { + const options = sanitizeShortcutOptions({ + saveQuery: { combo: 'Meta+W', enabled: true }, + }); + + expect(options.saveQuery).toEqual({ + mac: { combo: 'Meta+W', enabled: true }, + windows: { combo: 'Meta+W', enabled: true }, + }); + expect(options.closeActiveTab).toEqual({ + mac: { combo: 'Meta+W', enabled: false }, + windows: { combo: 'Ctrl+W', enabled: true }, + }); + }); + + it('respects an existing close active tab binding during sanitization', () => { + const options = sanitizeShortcutOptions({ + closeActiveTab: { + mac: { combo: 'Meta+Shift+W', enabled: false }, + windows: { combo: 'Ctrl+Shift+W', enabled: true }, + }, + saveQuery: { + mac: { combo: 'Meta+W', enabled: true }, + windows: { combo: 'Ctrl+W', enabled: true }, + }, + }); + + expect(options.closeActiveTab).toEqual({ + mac: { combo: 'Meta+Shift+W', enabled: false }, + windows: { combo: 'Ctrl+Shift+W', enabled: true }, + }); }); it('sanitizes partial platform shortcut bindings without losing defaults', () => { diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts index afbd4863..869967ab 100644 --- a/frontend/src/utils/shortcuts.ts +++ b/frontend/src/utils/shortcuts.ts @@ -13,6 +13,7 @@ export type ShortcutAction = | 'sendAIChatMessage' | 'focusSidebarSearch' | 'newQueryTab' + | 'closeActiveTab' | 'switchToNextTab' | 'switchToPreviousTab' | 'newConnection' @@ -114,6 +115,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [ 'sendAIChatMessage', 'focusSidebarSearch', 'newQueryTab', + 'closeActiveTab', 'switchToNextTab', 'switchToPreviousTab', 'newConnection', @@ -206,6 +208,12 @@ const SHORTCUT_ACTION_META_DEFINITIONS: Record { globalImeCompositionActive = active === true; @@ -474,6 +487,12 @@ export const setGlobalImeCompositionActive = (active: boolean): void => { export const isGlobalImeCompositionActive = (): boolean => globalImeCompositionActive; +export const setGlobalShortcutCaptureActive = (active: boolean): void => { + globalShortcutCaptureActive = active === true; +}; + +export const isGlobalShortcutCaptureActive = (): boolean => globalShortcutCaptureActive; + type ImeCompositionEventTarget = Pick; type ImeCompositionDocumentTarget = Pick & { visibilityState?: DocumentVisibilityState; @@ -604,10 +623,7 @@ const isUsableShortcutKey = (key: string): boolean => ( && key !== 'Dead' ); -const eventToShortcutCandidates = (event: KeyboardEvent | ReactKeyboardEvent): string[] => { - if (isImeComposingKeyEvent(event)) { - return []; - } +const eventToPhysicalShortcutCandidates = (event: KeyboardEvent | ReactKeyboardEvent): string[] => { const modifiers = resolveShortcutModifiersFromEvent(event); const candidates: string[] = []; const pushCandidate = (key: string) => { @@ -636,16 +652,30 @@ const eventToShortcutCandidates = (event: KeyboardEvent | ReactKeyboardEvent): s return candidates; }; +const eventToShortcutCandidates = (event: KeyboardEvent | ReactKeyboardEvent): string[] => { + if (isImeComposingKeyEvent(event)) { + return []; + } + return eventToPhysicalShortcutCandidates(event); +}; + export const eventToShortcut = (event: KeyboardEvent | ReactKeyboardEvent): string => { return eventToShortcutCandidates(event)[0] || ''; }; export const isShortcutMatch = (event: KeyboardEvent | ReactKeyboardEvent, combo: string): boolean => { + if (globalShortcutCaptureActive) return false; const expected = normalizeShortcutCombo(combo); if (!expected) return false; return eventToShortcutCandidates(event).includes(expected); }; +export const isShortcutPhysicalMatch = (event: KeyboardEvent | ReactKeyboardEvent, combo: string): boolean => { + const expected = normalizeShortcutCombo(combo); + if (!expected) return false; + return eventToPhysicalShortcutCandidates(event).includes(expected); +}; + export const getShortcutPlatform = (isMacRuntime?: boolean): ShortcutPlatform => ( isMacRuntime ? 'mac' : 'windows' ); @@ -735,6 +765,10 @@ const sanitizeShortcutPlatformBinding = ( export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => { const raw = (value && typeof value === 'object') ? value as Record : {}; const defaults = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS); + const hasPersistedCloseActiveTab = Object.prototype.hasOwnProperty.call(raw, 'closeActiveTab'); + const hasPersistedShortcutAction = SHORTCUT_ACTION_ORDER.some((action) => ( + action !== 'closeActiveTab' && Object.prototype.hasOwnProperty.call(raw, action) + )); SHORTCUT_ACTION_ORDER.forEach((action) => { const actionRaw = raw[action]; @@ -755,6 +789,21 @@ export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => { }; }); + if (!hasPersistedCloseActiveTab && hasPersistedShortcutAction) { + (['mac', 'windows'] as const).forEach((platform) => { + const closeBinding = defaults.closeActiveTab[platform]; + const closeCombo = normalizeShortcutCombo(closeBinding.combo); + const occupied = SHORTCUT_ACTION_ORDER.some((action) => { + if (action === 'closeActiveTab') return false; + const binding = defaults[action][platform]; + return binding.enabled && normalizeShortcutCombo(binding.combo) === closeCombo; + }); + if (occupied) { + defaults.closeActiveTab[platform] = { ...closeBinding, enabled: false }; + } + }); + } + return defaults; }; @@ -861,7 +910,6 @@ const RESERVED_SHORTCUT_DEFINITIONS: ReservedShortcutDefinition[] = [ // Browser / WebView built-in shortcuts { combo: 'Ctrl+S', labelKey: 'app.shortcuts.reserved.browser_save', context: 'global' }, { combo: 'Ctrl+P', labelKey: 'app.shortcuts.reserved.browser_print', context: 'global' }, - { combo: 'Ctrl+W', labelKey: 'app.shortcuts.reserved.browser_close_tab', context: 'global' }, { combo: 'Ctrl+T', labelKey: 'app.shortcuts.reserved.browser_new_tab', context: 'global' }, { combo: 'Ctrl+N', labelKey: 'app.shortcuts.reserved.browser_new_window', context: 'global' }, { combo: 'Ctrl+Shift+N', labelKey: 'app.shortcuts.reserved.browser_new_incognito_window', context: 'global' }, diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 74468f3e..30f20939 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2732,6 +2732,8 @@ "app.shortcuts.action.duplicateCurrentLine.label": "Aktuelle Zeile darunter duplizieren", "app.shortcuts.action.focusSidebarSearch.description": "Fokussiert das Suchfeld der linken Verbindungsstruktur", "app.shortcuts.action.focusSidebarSearch.label": "Sidebar-Suche fokussieren", + "app.shortcuts.action.closeActiveTab.description": "Aktiven Ergebnis- oder Arbeitsbereich-Tab abhängig vom aktuellen Bereich schließen", + "app.shortcuts.action.closeActiveTab.label": "Aktiven Tab schließen", "app.shortcuts.action.formatSql.description": "SQL im aktuellen Abfrageeditor formatieren", "app.shortcuts.action.formatSql.label": "SQL formatieren", "app.shortcuts.action.newConnection.description": "Neue Datenbank-, Runtime- oder andere Datenquellenverbindung erstellen", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 79877c29..52d3ffea 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2732,6 +2732,8 @@ "app.shortcuts.action.duplicateCurrentLine.label": "Duplicate Current Line Below", "app.shortcuts.action.focusSidebarSearch.description": "Focus the left connection tree search box", "app.shortcuts.action.focusSidebarSearch.label": "Focus Sidebar Search", + "app.shortcuts.action.closeActiveTab.description": "Close the active result tab or workspace tab based on the current area", + "app.shortcuts.action.closeActiveTab.label": "Close Active Tab", "app.shortcuts.action.formatSql.description": "Format SQL in the current query editor", "app.shortcuts.action.formatSql.label": "Format SQL", "app.shortcuts.action.newConnection.description": "Create a new database, runtime, or other data source connection", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 471df7ea..757bdcb2 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2732,6 +2732,8 @@ "app.shortcuts.action.duplicateCurrentLine.label": "現在の行を下に複製", "app.shortcuts.action.focusSidebarSearch.description": "左側の接続ツリー検索ボックスにフォーカスします", "app.shortcuts.action.focusSidebarSearch.label": "サイドバー検索にフォーカス", + "app.shortcuts.action.closeActiveTab.description": "現在の領域に応じて、アクティブな結果タブまたはワークスペースタブを閉じます", + "app.shortcuts.action.closeActiveTab.label": "アクティブなタブを閉じる", "app.shortcuts.action.formatSql.description": "現在のクエリエディターの SQL を整形します", "app.shortcuts.action.formatSql.label": "SQL を整形", "app.shortcuts.action.newConnection.description": "新しいデータベース、ランタイム、またはその他のデータソース接続を作成します", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 89af6b7f..ab641bf2 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2732,6 +2732,8 @@ "app.shortcuts.action.duplicateCurrentLine.label": "Дублировать текущую строку ниже", "app.shortcuts.action.focusSidebarSearch.description": "Перейти к полю поиска в левом дереве подключений", "app.shortcuts.action.focusSidebarSearch.label": "Фокус на поиске боковой панели", + "app.shortcuts.action.closeActiveTab.description": "Закрыть активную вкладку результатов или рабочего пространства в зависимости от текущей области", + "app.shortcuts.action.closeActiveTab.label": "Закрыть активную вкладку", "app.shortcuts.action.formatSql.description": "Форматировать SQL в текущем редакторе запросов", "app.shortcuts.action.formatSql.label": "Форматировать SQL", "app.shortcuts.action.newConnection.description": "Создать новое подключение к базе данных, runtime или другому источнику данных", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 7ccb8226..c0f41fa5 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2732,6 +2732,8 @@ "app.shortcuts.action.duplicateCurrentLine.label": "复制当前行到下一行", "app.shortcuts.action.focusSidebarSearch.description": "定位到左侧连接树搜索框", "app.shortcuts.action.focusSidebarSearch.label": "聚焦侧边栏搜索", + "app.shortcuts.action.closeActiveTab.description": "根据当前活动区域关闭结果标签页或工作区标签页", + "app.shortcuts.action.closeActiveTab.label": "关闭当前标签页", "app.shortcuts.action.formatSql.description": "格式化当前查询编辑器中的 SQL", "app.shortcuts.action.formatSql.label": "美化 SQL", "app.shortcuts.action.newConnection.description": "创建新的数据库、运行时或其他数据源连接", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 77938dbf..a9706a75 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2732,6 +2732,8 @@ "app.shortcuts.action.duplicateCurrentLine.label": "複製目前行到下一行", "app.shortcuts.action.focusSidebarSearch.description": "定位到左側連線樹搜尋框", "app.shortcuts.action.focusSidebarSearch.label": "聚焦側邊欄搜尋", + "app.shortcuts.action.closeActiveTab.description": "依目前作用中的區域關閉結果標籤頁或工作區標籤頁", + "app.shortcuts.action.closeActiveTab.label": "關閉目前標籤頁", "app.shortcuts.action.formatSql.description": "格式化目前查詢編輯器中的 SQL", "app.shortcuts.action.formatSql.label": "美化 SQL", "app.shortcuts.action.newConnection.description": "建立新的資料庫、執行階段或其他資料來源連線",