mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 00:20:22 +08:00
✨ feat(sidebar): 支持删除与清空近期查询
- 为每条近期查询增加删除入口,并在标题区提供清空操作 - 通过持久化可见性标记隐藏近期记录,保留完整 SQL 执行日志 - 补齐六语言文案及交互、订阅和状态持久化测试
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -637,6 +637,7 @@ export type V2CommandSearchItem =
|
||||
title: string;
|
||||
meta: string;
|
||||
icon: ReactNode;
|
||||
logId: string;
|
||||
sql: string;
|
||||
connectionId?: string;
|
||||
dbName?: string;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user