diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f9a2afb..6a040d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4197,6 +4197,23 @@ function App() { 新版 UI 仍在 Beta,部分屏幕样式可能与旧版有差异,遇到问题可随时切回。 )} + {appearance.uiVersion === 'v2' && ( +
+
新版左侧搜索模式
+ setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })} + /> +
+ 新版命令搜索适合跳转连接、表和动作,可在面板中开启同步开关持续过滤左侧树;旧版侧栏筛选会直接显示输入框并持久保留筛选内容。 +
+
+ )}
主题模式
diff --git a/frontend/src/App.ui-version.test.ts b/frontend/src/App.ui-version.test.ts index e7e8c0f..26fedf2 100644 --- a/frontend/src/App.ui-version.test.ts +++ b/frontend/src/App.ui-version.test.ts @@ -30,6 +30,9 @@ describe('UI version switch placement', () => { expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key as 'legacy' | 'v2' })}"); expect(appSource).toContain('新版 UI 仍在 Beta'); expect(appSource).toContain('Windows、macOS 与 Linux 均可切换'); + expect(appSource).toContain('新版左侧搜索模式'); + expect(appSource).toContain("value={appearance.v2SidebarSearchMode ?? 'command'}"); + expect(appSource).toContain("setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })"); }); it('uses the card-style v2 switch from the redesign instead of the segmented pill', () => { @@ -42,6 +45,7 @@ describe('UI version switch placement', () => { expect(uiVersionBlock).toContain("label: '旧版 UI'"); expect(uiVersionBlock).toContain("label: '新版 UI'"); expect(uiVersionBlock).toContain('CheckOutlined'); - expect(uiVersionBlock).not.toContain(' { expect(markup).toContain('gn-v2-object-explorer'); expect(markup).toContain('gn-v2-active-connection-header'); expect(markup).toContain('gn-v2-explorer-search'); + expect(markup).toContain('data-v2-sidebar-search-mode="command"'); expect(markup).toContain('gn-v2-explorer-command-trigger'); + expect(markup).toContain('gn-v2-explorer-filter-action'); + expect(markup).toContain('重置侧栏筛选'); expect(markup).toContain('搜索表、连接、动作... 或问 AI'); expect(markup).toContain('gn-v2-search-shortcut'); expect(markup).toContain(''); @@ -453,6 +456,11 @@ describe('Sidebar locate toolbar', () => { expect(markup).toContain('函数'); expect(markup).toContain('aria-pressed="true"'); expect(source).toContain("const [v2ExplorerFilter, setV2ExplorerFilter] = useState('all');"); + expect(source).toContain("const v2SidebarSearchMode = appearance.v2SidebarSearchMode ?? 'command';"); + expect(source).toContain('const v2CommandSearchPersistentFilterEnabled = appearance.v2CommandSearchPersistentFilterEnabled === true;'); + expect(source).toContain('handleV2CommandSearchValueChange(event.target.value)'); + expect(source).toContain('toggleV2CommandSearchPersistentFilter'); + expect(source).toContain('gn-v2-command-filter-switch'); expect(source).toContain('onClick={() => setV2ExplorerFilter(item.key)}'); expect(source).toContain('treeData={isV2Ui ? v2VisibleTreeData : displayTreeData}'); expect(markup).toContain('gn-v2-sidebar-log-footer'); @@ -496,6 +504,18 @@ describe('Sidebar locate toolbar', () => { expect(contextMenuFunction).not.toContain('setActiveContext'); }); + it('can render the v2 sidebar with legacy persistent filter input', () => { + mocks.state.appearance.v2SidebarSearchMode = 'filter'; + mocks.state.appearance.v2SidebarPersistedFilter = 'fs_org'; + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-v2-sidebar-search-mode="filter"'); + expect(markup).toContain('筛选左侧表、连接、对象...'); + expect(markup).toContain('value="fs_org"'); + expect(markup).toContain('重置侧栏筛选'); + }); + it('renders the v2 search shortcut from the user shortcut settings', () => { mocks.state.shortcutOptions = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS); mocks.state.shortcutOptions.focusSidebarSearch.mac = { combo: 'Meta+F', enabled: true }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index f06a07b..842688f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; -import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress } from 'antd'; +import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress, Switch } from 'antd'; import { DatabaseOutlined, TableOutlined, @@ -954,6 +954,7 @@ const Sidebar: React.FC<{ const addSqlLog = useStore(state => state.addSqlLog); const sqlLogs = useStore(state => state.sqlLogs) || []; const shortcutOptions = useStore(state => state.shortcutOptions); + const setAppearance = useStore(state => state.setAppearance); const setAIPanelVisible = useStore(state => state.setAIPanelVisible); const addAIContext = useStore(state => state.addAIContext); const darkMode = theme === 'dark'; @@ -966,6 +967,7 @@ const Sidebar: React.FC<{ const focusSidebarSearchShortcutTokens = focusSidebarSearchShortcut === '-' ? [] : focusSidebarSearchShortcut.match(/Ctrl|Alt|Shift|Esc|Space|[⌘⌃⌥⇧↵↑↓←→]|[^+]/g) ?? []; + const isV2Ui = (uiVersion ?? appearance.uiVersion) === 'v2'; const [treeData, setTreeData] = useState([]); const activeTab = useMemo(() => tabs.find(tab => tab.id === activeTabId) || null, [tabs, activeTabId]); const activeTabLocateRequest = useMemo(() => normalizeSidebarLocateObjectRequestFromTab(activeTab), [activeTab]); @@ -1021,7 +1023,11 @@ const Sidebar: React.FC<{
); - const [searchValue, setSearchValue] = useState(''); + const v2SidebarSearchMode = appearance.v2SidebarSearchMode ?? 'command'; + const v2UseLegacySidebarFilter = isV2Ui && v2SidebarSearchMode === 'filter'; + const v2CommandSearchPersistentFilterEnabled = appearance.v2CommandSearchPersistentFilterEnabled === true; + const v2PersistedSidebarFilter = appearance.v2SidebarPersistedFilter ?? ''; + const [searchValue, setSearchValue] = useState(v2PersistedSidebarFilter); const deferredSearchValue = useDeferredValue(searchValue); const [searchScopes, setSearchScopes] = useState(['smart']); const [v2ExplorerFilter, setV2ExplorerFilter] = useState('all'); @@ -1093,6 +1099,49 @@ const Sidebar: React.FC<{ setV2CommandSearchValue(''); setV2CommandActiveIndex(0); }, []); + + useEffect(() => { + setSearchValue(v2PersistedSidebarFilter); + }, [v2PersistedSidebarFilter]); + + useEffect(() => { + if (!v2UseLegacySidebarFilter) { + return; + } + const nextFilter = searchValue.trim(); + if (nextFilter !== v2PersistedSidebarFilter) { + setAppearance({ v2SidebarPersistedFilter: nextFilter }); + } + }, [searchValue, setAppearance, v2PersistedSidebarFilter, v2UseLegacySidebarFilter]); + + const handleV2CommandSearchValueChange = useCallback((value: string) => { + setV2CommandSearchValue(value); + if (!v2CommandSearchPersistentFilterEnabled) { + return; + } + const nextFilter = value.trim(); + setSearchValue(nextFilter); + setAppearance({ v2SidebarPersistedFilter: nextFilter }); + }, [setAppearance, v2CommandSearchPersistentFilterEnabled]); + + const toggleV2CommandSearchPersistentFilter = useCallback((enabled: boolean) => { + const nextFilter = enabled ? v2CommandSearchValue.trim() : ''; + setSearchValue(nextFilter); + setAppearance({ + v2CommandSearchPersistentFilterEnabled: enabled, + v2SidebarPersistedFilter: nextFilter, + }); + message.success(enabled ? '已开启左侧筛选同步' : '已关闭左侧筛选同步'); + }, [setAppearance, v2CommandSearchValue]); + + const resetV2SidebarFilter = useCallback(() => { + setSearchValue(''); + setAppearance({ + v2CommandSearchPersistentFilterEnabled: false, + v2SidebarPersistedFilter: '', + }); + message.success('已重置侧栏筛选'); + }, [setAppearance]); // Virtual Scroll State const [treeHeight, setTreeHeight] = useState(500); @@ -1121,7 +1170,7 @@ const Sidebar: React.FC<{ useEffect(() => { const handleFocusSidebarSearch = () => { - if ((uiVersion ?? appearance.uiVersion) === 'v2') { + if (isV2Ui && !v2UseLegacySidebarFilter) { openV2CommandSearch(); return; } @@ -1136,7 +1185,7 @@ const Sidebar: React.FC<{ return () => { window.removeEventListener('gonavi:focus-sidebar-search', handleFocusSidebarSearch as EventListener); }; - }, [appearance.uiVersion, openV2CommandSearch, uiVersion]); + }, [isV2Ui, openV2CommandSearch, v2UseLegacySidebarFilter]); useEffect(() => { if (!isV2CommandSearchOpen) return; @@ -3110,8 +3159,6 @@ const Sidebar: React.FC<{ }); }; - const isV2Ui = (uiVersion ?? appearance.uiVersion) === 'v2'; - const onSelect = (keys: React.Key[], info: any) => { if (isV2Ui && info?.node?.type === 'v2-table-section') { return; @@ -6792,15 +6839,15 @@ const Sidebar: React.FC<{ setV2CommandActiveIndex((prev) => Math.max(prev - 1, 0)); return; } - if (event.key === 'Enter') { - event.preventDefault(); - if (v2CommandSearchAiMode && !v2CommandSearchQuery.aiPrompt) { - message.warning('请输入要问 AI 的问题'); - return; - } - runCommandSearchItem(commandSearchFlatItems[v2CommandActiveIndex]); - return; - } + if (event.key === 'Enter') { + event.preventDefault(); + if (v2CommandSearchAiMode && !v2CommandSearchQuery.aiPrompt) { + message.warning('请输入要问 AI 的问题'); + return; + } + runCommandSearchItem(commandSearchFlatItems[v2CommandActiveIndex]); + return; + } if (event.key === 'Escape') { event.preventDefault(); closeV2CommandSearch(); @@ -6855,10 +6902,29 @@ const Sidebar: React.FC<{ ref={commandSearchInputRef} variant="borderless" value={v2CommandSearchValue} - onChange={(event) => setV2CommandSearchValue(event.target.value)} + onChange={(event) => handleV2CommandSearchValueChange(event.target.value)} onKeyDown={handleV2CommandSearchKeyDown} placeholder="搜索表、连接、动作... 或问 AI" /> + + + + + + + + {isV2Ui && !v2UseLegacySidebarFilter ? ( +
+ + + + +
+ ) : isV2Ui ? ( +
+ } + /> + + + +
) : ( { expect(appearance.opacity).toBe(0.75); expect(appearance.blur).toBe(6); expect(appearance.useNativeMacWindowControls).toBe(true); + expect(appearance.v2SidebarSearchMode).toBe('command'); + expect(appearance.v2CommandSearchPersistentFilterEnabled).toBe(false); + expect(appearance.v2SidebarPersistedFilter).toBe(''); expect(appearance.showDataTableVerticalBorders).toBe(false); expect(appearance.dataTableDensity).toBe('comfortable'); expect(appearance.dataTableFontSize).toBeNull(); @@ -124,6 +127,30 @@ describe('store appearance persistence', () => { expect(appearance.customMonoFontFamily).toBeNull(); }); + it('persists v2 sidebar search preferences and sanitizes filter text', async () => { + const { useStore } = await importStore(); + + useStore.getState().setAppearance({ + v2SidebarSearchMode: 'filter', + v2CommandSearchPersistentFilterEnabled: true, + v2SidebarPersistedFilter: ` ${'orders'.repeat(40)} `, + }); + + const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}'); + expect(persisted.state.appearance.v2SidebarSearchMode).toBe('filter'); + expect(persisted.state.appearance.v2CommandSearchPersistentFilterEnabled).toBe(true); + expect(persisted.state.appearance.v2SidebarPersistedFilter).toHaveLength(120); + expect(persisted.state.appearance.v2SidebarPersistedFilter.startsWith('orders')).toBe(true); + + vi.resetModules(); + const reloaded = await importStore(); + const appearance = reloaded.useStore.getState().appearance; + + expect(appearance.v2SidebarSearchMode).toBe('filter'); + expect(appearance.v2CommandSearchPersistentFilterEnabled).toBe(true); + expect(appearance.v2SidebarPersistedFilter).toHaveLength(120); + }); + it('persists tab display appearance settings and sanitizes invalid elements', async () => { const { useStore } = await importStore(); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 34b71f9..4f27669 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -59,6 +59,9 @@ export interface AppearanceSettings extends DataGridDisplaySettings { opacity: number; blur: number; useNativeMacWindowControls: boolean; + v2SidebarSearchMode: "command" | "filter"; + v2CommandSearchPersistentFilterEnabled: boolean; + v2SidebarPersistedFilter: string; customUIFontFamily: string | null; customMonoFontFamily: string | null; tabDisplay: TabDisplaySettings; @@ -70,6 +73,9 @@ export const DEFAULT_APPEARANCE: AppearanceSettings = { opacity: 1.0, blur: 0, useNativeMacWindowControls: false, + v2SidebarSearchMode: "command", + v2CommandSearchPersistentFilterEnabled: false, + v2SidebarPersistedFilter: "", customUIFontFamily: null, customMonoFontFamily: null, tabDisplay: DEFAULT_TAB_DISPLAY_SETTINGS, @@ -84,6 +90,20 @@ const MAX_FONT_SIZE = 20; const DEFAULT_STARTUP_FULLSCREEN = false; const LEGACY_DEFAULT_OPACITY = 0.95; const OPACITY_EPSILON = 1e-6; +const MAX_SIDEBAR_PERSISTED_FILTER_LENGTH = 120; + +const sanitizeV2SidebarSearchMode = ( + value: unknown, +): AppearanceSettings["v2SidebarSearchMode"] => { + return value === "filter" ? "filter" : DEFAULT_APPEARANCE.v2SidebarSearchMode; +}; + +const sanitizeV2SidebarPersistedFilter = (value: unknown): string => { + if (typeof value !== "string") { + return DEFAULT_APPEARANCE.v2SidebarPersistedFilter; + } + return value.trim().slice(0, MAX_SIDEBAR_PERSISTED_FILTER_LENGTH); +}; const MAX_URI_LENGTH = 4096; const MAX_HOST_ENTRY_LENGTH = 512; const MAX_HOST_ENTRIES = 64; @@ -1637,6 +1657,16 @@ const sanitizeAppearance = ( typeof appearance.useNativeMacWindowControls === "boolean" ? appearance.useNativeMacWindowControls : DEFAULT_APPEARANCE.useNativeMacWindowControls, + v2SidebarSearchMode: sanitizeV2SidebarSearchMode( + appearance.v2SidebarSearchMode, + ), + v2CommandSearchPersistentFilterEnabled: + typeof appearance.v2CommandSearchPersistentFilterEnabled === "boolean" + ? appearance.v2CommandSearchPersistentFilterEnabled + : DEFAULT_APPEARANCE.v2CommandSearchPersistentFilterEnabled, + v2SidebarPersistedFilter: sanitizeV2SidebarPersistedFilter( + appearance.v2SidebarPersistedFilter, + ), customUIFontFamily: sanitizeFontFamilyInput(appearance.customUIFontFamily), customMonoFontFamily: sanitizeFontFamilyInput(appearance.customMonoFontFamily), tabDisplay: sanitizeTabDisplaySettings(appearance.tabDisplay), diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index bdf4bdf..aab8d91 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -1831,8 +1831,22 @@ body[data-ui-version="v2"] .gn-v2-explorer-search { border-bottom: none !important; } +body[data-ui-version="v2"] .gn-v2-explorer-command-row, +body[data-ui-version="v2"] .gn-v2-explorer-legacy-filter-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +body[data-ui-version="v2"] .gn-v2-explorer-legacy-filter-row .ant-input-affix-wrapper { + flex: 1 1 auto; + min-width: 0; +} + body[data-ui-version="v2"] .gn-v2-explorer-command-trigger { - width: 100%; + flex: 1 1 auto; + min-width: 0; height: 30px; display: flex; align-items: center; @@ -1862,6 +1876,31 @@ body[data-ui-version="v2"] .gn-v2-explorer-command-trigger > span:nth-child(2) { white-space: nowrap; } +body[data-ui-version="v2"] .gn-v2-explorer-filter-action { + width: 30px; + height: 30px; + flex: 0 0 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0.5px solid var(--gn-br-2); + border-radius: 7px; + background: var(--gn-bg-input); + color: var(--gn-fg-4); + cursor: pointer; +} + +body[data-ui-version="v2"] .gn-v2-explorer-filter-action:hover:not(:disabled) { + border-color: var(--gn-accent); + background: var(--gn-bg-hover); + color: var(--gn-fg-2); +} + +body[data-ui-version="v2"] .gn-v2-explorer-filter-action:disabled { + opacity: 0.42; + cursor: not-allowed; +} + body[data-ui-version="v2"] .gn-v2-command-backdrop { position: fixed; inset: 0; @@ -1918,6 +1957,38 @@ body[data-ui-version="v2"] .gn-v2-command-searchbar > .anticon { color: var(--gn-fg-4); } +body[data-ui-version="v2"] .gn-v2-command-filter-switch { + height: 30px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; +} + +body[data-ui-version="v2"] .gn-v2-command-searchbar .ant-switch { + flex: 0 0 auto; +} + +body[data-ui-version="v2"] .gn-v2-command-searchbar .ant-btn { + width: 30px !important; + height: 30px !important; + flex: 0 0 30px; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + border-radius: 8px !important; + color: var(--gn-fg-4) !important; +} + +body[data-ui-version="v2"] .gn-v2-command-searchbar .ant-btn:hover:not(:disabled) { + background: var(--gn-bg-hover) !important; + color: var(--gn-fg-2) !important; +} + +body[data-ui-version="v2"] .gn-v2-command-searchbar .ant-btn:disabled { + color: var(--gn-fg-5) !important; +} + body[data-ui-version="v2"] .gn-v2-command-searchbar > kbd, body[data-ui-version="v2"] .gn-v2-command-footer kbd, body[data-ui-version="v2"] .gn-v2-command-row kbd {