feat(sidebar): 增强 v2 侧栏搜索持久筛选

- 新增 v2 侧栏搜索模式配置,支持新版命令搜索和旧版侧栏筛选切换
- 命令搜索面板增加同步筛选开关和重置筛选按钮
- 侧栏顶部支持展示并清空已同步筛选词
- 补充 appearance 持久化字段清洗和回归测试
This commit is contained in:
Syngnat
2026-06-02 13:40:20 +08:00
parent d800f1ce84
commit 3a2db112f3
7 changed files with 309 additions and 38 deletions

View File

@@ -4197,6 +4197,23 @@ function App() {
UI Beta
</div>
)}
{appearance.uiVersion === 'v2' && (
<div style={{ marginTop: 14 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Segmented
block
options={[
{ label: '新版命令搜索', value: 'command' },
{ label: '旧版侧栏筛选', value: 'filter' },
]}
value={appearance.v2SidebarSearchMode ?? 'command'}
onChange={(value) => setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })}
/>
<div style={{ ...utilityMutedTextStyle, marginTop: 8 }}>
</div>
</div>
)}
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}></div>

View File

@@ -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('<Segmented');
expect(uiVersionBlock).toContain('新版左侧搜索模式');
expect(uiVersionBlock).toContain('<Segmented');
});
});

View File

@@ -439,7 +439,10 @@ describe('Sidebar locate toolbar', () => {
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('<kbd>⌘</kbd>');
@@ -453,6 +456,11 @@ describe('Sidebar locate toolbar', () => {
expect(markup).toContain('函数');
expect(markup).toContain('aria-pressed="true"');
expect(source).toContain("const [v2ExplorerFilter, setV2ExplorerFilter] = useState<V2ExplorerFilter>('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(<Sidebar uiVersion="v2" />);
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 };

View File

@@ -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<TreeNode[]>([]);
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<{
</div>
</div>
);
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<SearchScope[]>(['smart']);
const [v2ExplorerFilter, setV2ExplorerFilter] = useState<V2ExplorerFilter>('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"
/>
<Tooltip title="同步输入内容到左侧筛选">
<span className="gn-v2-command-filter-switch" aria-label="同步到左侧筛选">
<Switch
size="small"
checked={v2CommandSearchPersistentFilterEnabled}
onChange={toggleV2CommandSearchPersistentFilter}
/>
</span>
</Tooltip>
<Tooltip title={v2PersistedSidebarFilter ? '重置侧栏筛选' : '没有已同步的侧栏筛选'}>
<Button
size="small"
type="text"
icon={<ReloadOutlined />}
aria-label="重置侧栏筛选"
disabled={!v2PersistedSidebarFilter}
onClick={resetV2SidebarFilter}
/>
</Tooltip>
<kbd>esc</kbd>
</div>
<div className="gn-v2-command-list">
@@ -8489,26 +8555,62 @@ const Sidebar: React.FC<{
</div>
)}
<div className={isV2Ui ? 'gn-v2-explorer-search' : undefined} style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
{isV2Ui ? (
<button
type="button"
className="gn-v2-explorer-command-trigger"
onClick={() => {
openV2CommandSearch();
onFocusCommandSearch?.();
}}
aria-label="搜索表、连接、动作"
>
<SearchOutlined />
<span>... AI</span>
{focusSidebarSearchShortcutTokens.length > 0 ? (
<span className="gn-v2-search-shortcut" aria-hidden="true">
{focusSidebarSearchShortcutTokens.map((token, index) => (
<kbd key={`${token}-${index}`}>{token}</kbd>
))}
</span>
) : null}
</button>
{isV2Ui && !v2UseLegacySidebarFilter ? (
<div className="gn-v2-explorer-command-row" data-v2-sidebar-search-mode="command">
<button
type="button"
className="gn-v2-explorer-command-trigger"
onClick={() => {
openV2CommandSearch();
onFocusCommandSearch?.();
}}
aria-label="搜索表、连接、动作"
>
<SearchOutlined />
<span>{v2PersistedSidebarFilter || '搜索表、连接、动作... 或问 AI'}</span>
{focusSidebarSearchShortcutTokens.length > 0 ? (
<span className="gn-v2-search-shortcut" aria-hidden="true">
{focusSidebarSearchShortcutTokens.map((token, index) => (
<kbd key={`${token}-${index}`}>{token}</kbd>
))}
</span>
) : null}
</button>
<Tooltip title={v2PersistedSidebarFilter ? '重置侧栏筛选' : '没有已同步的侧栏筛选'}>
<button
type="button"
className="gn-v2-explorer-filter-action"
aria-label="重置侧栏筛选"
disabled={!v2PersistedSidebarFilter}
onClick={resetV2SidebarFilter}
>
<ReloadOutlined />
</button>
</Tooltip>
</div>
) : isV2Ui ? (
<div className="gn-v2-explorer-legacy-filter-row" data-v2-sidebar-search-mode="filter">
<Input
{...noAutoCapInputProps}
ref={searchInputRef}
value={searchValue}
placeholder="筛选左侧表、连接、对象..."
onChange={onSearch}
size="small"
prefix={<SearchOutlined />}
/>
<Tooltip title={searchValue ? '重置侧栏筛选' : '没有筛选内容'}>
<button
type="button"
className="gn-v2-explorer-filter-action"
aria-label="重置侧栏筛选"
disabled={!searchValue}
onClick={resetV2SidebarFilter}
>
<ReloadOutlined />
</button>
</Tooltip>
</div>
) : (
<Input
{...noAutoCapInputProps}

View File

@@ -69,6 +69,9 @@ describe('store appearance persistence', () => {
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();

View File

@@ -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),

View File

@@ -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 {