feat(sidebar): 支持删除与清空近期查询

- 为每条近期查询增加删除入口,并在标题区提供清空操作
- 通过持久化可见性标记隐藏近期记录,保留完整 SQL 执行日志
- 补齐六语言文案及交互、订阅和状态持久化测试
This commit is contained in:
AutumnNazi
2026-07-24 12:35:56 +08:00
parent e43fc6e1a1
commit 0d27d2deaa
17 changed files with 321 additions and 22 deletions

View File

@@ -71,4 +71,17 @@ describe('Sidebar SQL log subscription', () => {
renderer.unmount();
});
});
it('filters hidden recent queries before applying the five-item limit', () => {
const logs = Array.from({ length: 7 }, (_, index) => makeLog(`log-${7 - index}`, 7 - index));
logs[1] = { ...logs[1], hiddenFromRecent: true };
expect(selectRecentSidebarSqlLogs(logs).map((log) => log.id)).toEqual([
'log-7',
'log-5',
'log-4',
'log-3',
'log-2',
]);
});
});

View File

@@ -572,6 +572,8 @@ const Sidebar: React.FC<{
const queryOptions = useStore(state => state.queryOptions);
const setQueryOptions = useStore(state => state.setQueryOptions);
const addSqlLog = useStore(state => state.addSqlLog);
const hideSqlLogFromRecent = useStore(state => state.hideSqlLogFromRecent);
const clearRecentSqlLogs = useStore(state => state.clearRecentSqlLogs);
const shortcutOptions = useStore(state => state.shortcutOptions);
const languagePreference = useStore(state => state.languagePreference);
const setAppearance = useStore(state => state.setAppearance);
@@ -2970,6 +2972,10 @@ const Sidebar: React.FC<{
onClose: closeV2CommandSearch,
onItemSelect: (item: V2CommandSearchItem) => runCommandSearchItem(item),
onItemHover: (key: string) => setV2CommandActiveIndex(commandSearchFlatItems.findIndex((entry) => entry.key === key)),
onRemoveRecentItem: (item: V2CommandSearchItem) => {
if (item.kind === 'recent') hideSqlLogFromRecent(item.logId);
},
onClearRecentItems: clearRecentSqlLogs,
onTogglePersistentFilter: toggleV2CommandSearchPersistentFilter,
onResetFilter: resetV2SidebarFilter,
},

View File

@@ -18,6 +18,8 @@ const requiredKeys = [
'sidebar.command_search.action.open_ai.meta',
'sidebar.command_search.action.open_sql_log.title',
'sidebar.command_search.action.open_sql_log.meta',
'sidebar.command_search.action.clear_recent',
'sidebar.command_search.action.remove_recent',
'sidebar.command_search.empty.ai',
'sidebar.command_search.empty.object',
'sidebar.command_search.empty.default',

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { act, create } from 'react-test-renderer';
import { afterEach, describe, expect, it, vi } from 'vitest';
import SidebarSearchPanel from './SidebarSearchPanel';
vi.mock('react-dom', () => ({
createPortal: (children: React.ReactNode) => children,
}));
vi.mock('antd', async () => {
const React = await import('react');
const passthrough = ({ children }: { children?: React.ReactNode }) => <>{children}</>;
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => <input ref={ref} {...props} />,
);
return {
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
ConfigProvider: passthrough,
Input,
Switch: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input type="checkbox" {...props} />,
Tooltip: passthrough,
};
});
vi.mock('@ant-design/icons', () => {
const Icon = () => <span data-icon="true" />;
return {
CloseOutlined: Icon,
ReloadOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
TableOutlined: Icon,
};
});
vi.mock('../../i18n', () => ({
t: (key: string) => key,
}));
const recentItem = {
key: 'recent-log-1',
kind: 'recent' as const,
title: 'SELECT 1',
meta: '10:30 · 12ms',
icon: <span />,
};
describe('SidebarSearchPanel recent query actions', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('removes one recent query without selecting its row and clears the section independently', async () => {
vi.stubGlobal('document', { body: {} });
const onItemSelect = vi.fn();
const onRemoveRecentItem = vi.fn();
const onClearRecentItems = vi.fn();
const renderer = create(
<SidebarSearchPanel
isOpen
searchValue=""
activeIndex={0}
label="Search"
placeholder="Search"
persistedFilter=""
persistentFilterEnabled={false}
aiMode={false}
objectMode={false}
flatItems={[recentItem]}
sections={{ goTo: [], ai: [], actions: [], recent: [recentItem] }}
inputRef={{ current: null }}
handlers={{
onSearchValueChange: vi.fn(),
onKeyDown: vi.fn(),
onClose: vi.fn(),
onItemSelect,
onItemHover: vi.fn(),
onTogglePersistentFilter: vi.fn(),
onResetFilter: vi.fn(),
onRemoveRecentItem,
onClearRecentItems,
}}
/>,
);
const removeButton = renderer.root.findByProps({ className: 'gn-v2-command-row-remove' });
const removeMouseDown = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
await act(async () => {
removeButton.props.onMouseDown(removeMouseDown);
removeButton.props.onClick({ stopPropagation: vi.fn() });
});
expect(removeMouseDown.preventDefault).toHaveBeenCalledTimes(1);
expect(removeMouseDown.stopPropagation).toHaveBeenCalledTimes(1);
expect(onRemoveRecentItem).toHaveBeenCalledWith(recentItem);
expect(onItemSelect).not.toHaveBeenCalled();
const clearButton = renderer.root.findByProps({ className: 'gn-v2-command-section-clear' });
const clearMouseDown = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
await act(async () => {
clearButton.props.onMouseDown(clearMouseDown);
clearButton.props.onClick({ stopPropagation: vi.fn() });
});
expect(clearMouseDown.preventDefault).toHaveBeenCalledTimes(1);
expect(clearMouseDown.stopPropagation).toHaveBeenCalledTimes(1);
expect(onClearRecentItems).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { ConfigProvider, Input, Button, Switch, Tooltip } from 'antd';
import { SearchOutlined, ReloadOutlined, TableOutlined, RobotOutlined } from '@ant-design/icons';
import { CloseOutlined, SearchOutlined, ReloadOutlined, TableOutlined, RobotOutlined } from '@ant-design/icons';
import { noAutoCapInputProps } from '../../utils/inputAutoCap';
import { t } from '../../i18n';
import { APP_COMMAND_PALETTE_Z_INDEX } from '../../utils/overlayZIndex';
@@ -47,6 +47,8 @@ export interface SidebarSearchPanelProps<TItem extends V2CommandSearchItemLike =
onClose: () => void;
onItemSelect: (item: TItem) => void;
onItemHover: (key: string) => void;
onRemoveRecentItem: (item: TItem) => void;
onClearRecentItems: () => void;
onTogglePersistentFilter: (enabled: boolean) => void;
onResetFilter: () => void;
};
@@ -76,28 +78,69 @@ const SidebarSearchPanel = <TItem extends V2CommandSearchItemLike>({
: t('sidebar.command_search.empty.default');
const renderRow = (item: TItem, active: boolean) => (
<button
<div
key={item.key}
type="button"
className={`gn-v2-command-row${active ? ' is-active' : ''}`}
className={`gn-v2-command-row-shell${active ? ' is-active' : ''}`}
onMouseEnter={() => handlers.onItemHover(item.key)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => handlers.onItemSelect(item)}
>
<span className={`gn-v2-command-row-icon is-${item.kind}`}>{item.icon}</span>
<span className="gn-v2-command-row-main">
<strong>{item.title}</strong>
{item.meta ? <small>{item.meta}</small> : null}
</span>
{item.kind === 'action' && item.shortcut ? <kbd>{item.shortcut}</kbd> : null}
</button>
<button
type="button"
className="gn-v2-command-row"
onMouseDown={(event) => event.preventDefault()}
onClick={() => handlers.onItemSelect(item)}
>
<span className={`gn-v2-command-row-icon is-${item.kind}`}>{item.icon}</span>
<span className="gn-v2-command-row-main">
<strong>{item.title}</strong>
{item.meta ? <small>{item.meta}</small> : null}
</span>
{item.kind === 'action' && item.shortcut ? <kbd>{item.shortcut}</kbd> : null}
</button>
{item.kind === 'recent' ? (
<Tooltip title={t('sidebar.command_search.action.remove_recent')}>
<button
type="button"
className="gn-v2-command-row-remove"
aria-label={t('sidebar.command_search.action.remove_recent')}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
handlers.onRemoveRecentItem(item);
}}
>
<CloseOutlined />
</button>
</Tooltip>
) : null}
</div>
);
const renderSection = (title: string, items: TItem[]) => {
const renderSection = (title: string, items: TItem[], showClear = false) => {
if (items.length === 0) return null;
return (
<section className="gn-v2-command-section">
<div className="gn-v2-command-section-title">{title}</div>
<div className="gn-v2-command-section-heading">
<div className="gn-v2-command-section-title">{title}</div>
{showClear ? (
<button
type="button"
className="gn-v2-command-section-clear"
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
handlers.onClearRecentItems();
}}
>
{t('sidebar.command_search.action.clear_recent')}
</button>
) : null}
</div>
{items.map((item) =>
renderRow(item, flatItems[activeIndex]?.key === item.key),
)}
@@ -155,7 +198,7 @@ const SidebarSearchPanel = <TItem extends V2CommandSearchItemLike>({
{renderSection(t('sidebar.command_search.section.goto'), sections.goTo)}
{renderSection(t('sidebar.command_search.section.ai'), sections.ai)}
{renderSection(t('sidebar.command_search.section.actions'), sections.actions)}
{renderSection(t('sidebar.command_search.section.recent'), sections.recent)}
{renderSection(t('sidebar.command_search.section.recent'), sections.recent, true)}
{flatItems.length === 0 ? (
<div className="gn-v2-command-empty">{emptyCopy}</div>
) : null}

View File

@@ -9,5 +9,7 @@ export const selectSidebarCommandSearchSqlLogs = (
): SqlLog[] => (enabled ? state.sqlLogs : EMPTY_SIDEBAR_SQL_LOGS);
export const selectRecentSidebarSqlLogs = (sqlLogs: SqlLog[]): SqlLog[] => (
sqlLogs.slice(0, SIDEBAR_RECENT_SQL_LOG_LIMIT)
sqlLogs
.filter((log) => !log.hiddenFromRecent)
.slice(0, SIDEBAR_RECENT_SQL_LOG_LIMIT)
);

View File

@@ -442,6 +442,7 @@ export const useSidebarSearchModel = ({
title: log.sql.replace(/\s+/g, ' ').trim() || t('sidebar.command_search.recent_sql_fallback'),
meta: `${new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · ${log.duration}ms${log.dbName ? ` · ${log.dbName}` : ''}`,
icon: <ClockCircleOutlined />,
logId: log.id,
sql: log.sql,
dbName: log.dbName,
}));

View File

@@ -637,6 +637,7 @@ export type V2CommandSearchItem =
title: string;
meta: string;
icon: ReactNode;
logId: string;
sql: string;
connectionId?: string;
dbName?: string;

View File

@@ -2942,6 +2942,47 @@ describe('store appearance persistence', () => {
expect(reloaded.useStore.getState().sqlLogs[0]?.sql.length).toBe(12 * 1024);
});
it('hides recent queries without deleting their SQL execution logs', async () => {
const { useStore } = await importStore();
const makeLog = (id: string) => ({
id,
timestamp: 100,
sql: `select '${id}'`,
status: 'success' as const,
duration: 12,
});
useStore.getState().addSqlLog(makeLog('log-1'));
useStore.getState().addSqlLog(makeLog('log-2'));
useStore.getState().addSqlLog(makeLog('log-3'));
useStore.getState().hideSqlLogFromRecent('log-2');
expect(useStore.getState().sqlLogs.map((log) => log.id)).toEqual(['log-3', 'log-2', 'log-1']);
expect(useStore.getState().sqlLogs.find((log) => log.id === 'log-2')).toMatchObject({
hiddenFromRecent: true,
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.sqlLogs.map((log: { id: string }) => log.id)).toEqual(['log-3', 'log-2', 'log-1']);
expect(persisted.state.sqlLogs.find((log: { id: string }) => log.id === 'log-2')).toMatchObject({
hiddenFromRecent: true,
});
useStore.getState().clearRecentSqlLogs();
expect(useStore.getState().sqlLogs).toHaveLength(3);
expect(useStore.getState().sqlLogs.every((log) => log.hiddenFromRecent === true)).toBe(true);
useStore.getState().addSqlLog(makeLog('log-4'));
expect(useStore.getState().sqlLogs[0]).toMatchObject({ id: 'log-4' });
expect(useStore.getState().sqlLogs[0]?.hiddenFromRecent).toBeUndefined();
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().sqlLogs).toHaveLength(4);
expect(reloaded.useStore.getState().sqlLogs.find((log) => log.id === 'log-2')).toMatchObject({
hiddenFromRecent: true,
});
});
it('preserves SQL transaction log metadata across persistence', async () => {
const { useStore } = await importStore();

View File

@@ -1674,6 +1674,7 @@ export interface SqlLog {
sql: string;
status: "success" | "error";
duration: number;
hiddenFromRecent?: boolean;
message?: string;
dbName?: string;
affectedRows?: number;
@@ -1964,6 +1965,8 @@ interface AppState {
resetBuiltinSqlSnippet: (id: string) => void;
addSqlLog: (log: SqlLog) => void;
hideSqlLogFromRecent: (id: string) => void;
clearRecentSqlLogs: () => void;
clearSqlLogs: () => void;
upsertTableExportHistory: (
historyKey: string,
@@ -2627,6 +2630,9 @@ const sanitizeSqlLogEntry = (
if (message) {
log.message = message;
}
if (raw.hiddenFromRecent === true) {
log.hiddenFromRecent = true;
}
if (Number.isFinite(affectedRows)) {
log.affectedRows = affectedRows;
}
@@ -5130,6 +5136,18 @@ export const useStore = create<AppState>()(
addSqlLog: (log) =>
set((state) => ({ sqlLogs: appendRuntimeSqlLog(state.sqlLogs, log) })),
hideSqlLogFromRecent: (id) =>
set((state) => ({
sqlLogs: state.sqlLogs.map((log) => (
log.id === id ? { ...log, hiddenFromRecent: true } : log
)),
})),
clearRecentSqlLogs: () =>
set((state) => ({
sqlLogs: state.sqlLogs.map((log) => (
log.hiddenFromRecent ? log : { ...log, hiddenFromRecent: true }
)),
})),
clearSqlLogs: () => set({ sqlLogs: [] }),
upsertTableExportHistory: (historyKey, entry) =>
set((state) => {

View File

@@ -2431,8 +2431,15 @@ body[data-ui-version="v2"] .gn-v2-command-section {
margin-bottom: 16px;
}
body[data-ui-version="v2"] .gn-v2-command-section-title {
body[data-ui-version="v2"] .gn-v2-command-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 22px 8px;
}
body[data-ui-version="v2"] .gn-v2-command-section-title {
color: var(--gn-fg-4);
font-family: var(--gn-font-mono);
font-size: 12px;
@@ -2441,10 +2448,39 @@ body[data-ui-version="v2"] .gn-v2-command-section-title {
text-transform: uppercase;
}
body[data-ui-version="v2"] .gn-v2-command-row {
body[data-ui-version="v2"] .gn-v2-command-section-clear {
flex: 0 0 auto;
border: 0;
padding: 0;
background: transparent;
color: var(--gn-fg-4);
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
body[data-ui-version="v2"] .gn-v2-command-section-clear:hover,
body[data-ui-version="v2"] .gn-v2-command-section-clear:focus-visible {
color: var(--gn-danger);
}
body[data-ui-version="v2"] .gn-v2-command-row-shell {
width: 100%;
min-height: 48px;
display: flex;
align-items: stretch;
}
body[data-ui-version="v2"] .gn-v2-command-row-shell:hover,
body[data-ui-version="v2"] .gn-v2-command-row-shell.is-active {
background: var(--gn-bg-hover);
}
body[data-ui-version="v2"] .gn-v2-command-row {
min-width: 0;
min-height: 48px;
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 14px;
border: 0;
@@ -2455,9 +2491,22 @@ body[data-ui-version="v2"] .gn-v2-command-row {
text-align: left;
}
body[data-ui-version="v2"] .gn-v2-command-row:hover,
body[data-ui-version="v2"] .gn-v2-command-row.is-active {
background: var(--gn-bg-hover);
body[data-ui-version="v2"] .gn-v2-command-row-remove {
width: 44px;
flex: 0 0 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0;
background: transparent;
color: var(--gn-fg-5);
cursor: pointer;
}
body[data-ui-version="v2"] .gn-v2-command-row-remove:hover,
body[data-ui-version="v2"] .gn-v2-command-row-remove:focus-visible {
color: var(--gn-danger);
}
body[data-ui-version="v2"] .gn-v2-command-row-icon {