♻️ refactor(sidebar): 抽出 Command Search 面板组件

- 新建 SidebarSearchPanel,承接 V2 命令搜索弹层的行、分组和空状态渲染

- Sidebar.tsx 保留搜索状态、过滤和执行逻辑,仅通过 typed props 传入子组件

- 保持 @ 对象搜索、? AI 提问、同步过滤与键盘导航行为不变
This commit is contained in:
Syngnat
2026-06-19 14:55:21 +08:00
parent 2c8128724e
commit 540dbc2a28
2 changed files with 198 additions and 96 deletions

View File

@@ -1,5 +1,6 @@
import Modal from './common/ResizableDraggableModal';
import SidebarConnectionRail from './sidebar/SidebarConnectionRail';
import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel';
import {
V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID,
formatSidebarRowCount,
@@ -7527,101 +7528,6 @@ const Sidebar: React.FC<{
}
};
const renderV2CommandSearchRow = (item: V2CommandSearchItem, active: boolean) => (
<button
key={item.key}
type="button"
className={`gn-v2-command-row${active ? ' is-active' : ''}`}
onMouseEnter={() => setV2CommandActiveIndex(commandSearchFlatItems.findIndex((entry) => entry.key === item.key))}
onMouseDown={(event) => event.preventDefault()}
onClick={() => runCommandSearchItem(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>
);
const renderV2CommandSearchSection = (title: string, items: V2CommandSearchItem[]) => {
if (items.length === 0) return null;
return (
<section className="gn-v2-command-section">
<div className="gn-v2-command-section-title">{title}</div>
{items.map((item) => renderV2CommandSearchRow(
item,
commandSearchFlatItems[v2CommandActiveIndex]?.key === item.key,
))}
</section>
);
};
const renderV2CommandSearchOverlay = () => {
if (!isV2CommandSearchOpen) return null;
const emptyCopy = v2CommandSearchAiMode
? '输入「?」后加问题,按 Enter 发送到 AI 面板。'
: (v2CommandSearchObjectMode
? '未找到匹配的表、视图或物化视图。'
: '未找到匹配项。可输入 @表名 只搜表对象,或输入 ?问题 让 AI 回答。');
return (
<div className="gn-v2-command-backdrop" data-v2-command-search="true" onMouseDown={closeV2CommandSearch}>
<div className="gn-v2-command-palette" role="dialog" aria-modal="true" aria-label={v2CommandSearchLabel} onMouseDown={(event) => event.stopPropagation()}>
<div className="gn-v2-command-searchbar">
<SearchOutlined />
<Input
{...noAutoCapInputProps}
ref={commandSearchInputRef}
variant="borderless"
value={v2CommandSearchValue}
onChange={(event) => handleV2CommandSearchValueChange(event.target.value)}
onKeyDown={handleV2CommandSearchKeyDown}
placeholder={v2CommandSearchPlaceholder}
/>
<Tooltip title={t('sidebar.command_search.sync_to_filter_tooltip')}>
<span className="gn-v2-command-filter-switch" aria-label={t('sidebar.command_search.sync_to_filter_aria')}>
<Switch
size="small"
checked={v2CommandSearchPersistentFilterEnabled}
onChange={toggleV2CommandSearchPersistentFilter}
/>
</span>
</Tooltip>
<Tooltip title={v2PersistedSidebarFilter ? t('sidebar.command_search.reset_filter') : t('sidebar.command_search.no_synced_filter')}>
<Button
size="small"
type="text"
icon={<ReloadOutlined />}
aria-label={t('sidebar.command_search.reset_filter')}
disabled={!v2PersistedSidebarFilter}
onClick={resetV2SidebarFilter}
/>
</Tooltip>
<kbd>esc</kbd>
</div>
<div className="gn-v2-command-list">
{renderV2CommandSearchSection('跳转 · GO TO', filteredCommandSearchTreeItems)}
{renderV2CommandSearchSection('AI · ASK', commandSearchAiItem)}
{renderV2CommandSearchSection('动作 · ACTIONS', filteredCommandSearchActionItems)}
{renderV2CommandSearchSection('近期查询 · RECENT', filteredCommandSearchRecentItems)}
{commandSearchFlatItems.length === 0 ? (
<div className="gn-v2-command-empty">
{emptyCopy}
</div>
) : null}
</div>
<div className="gn-v2-command-footer">
<span><kbd></kbd><kbd></kbd></span>
<span><kbd></kbd></span>
<span><TableOutlined /> <kbd>@</kbd></span>
<span><RobotOutlined /> <kbd>?</kbd> AI</span>
</div>
</div>
</div>
);
};
expandConnectionFromRailRef.current = (connectionId: string) => {
const conn = connections.find((item) => item.id === connectionId);
if (conn) {
@@ -9163,6 +9069,35 @@ const Sidebar: React.FC<{
const v2CommandSearchLabel = t('sidebar.command_search.label');
const v2CommandSearchPlaceholder = t('sidebar.command_search.placeholder');
const v2CommandSearchPanelProps: SidebarSearchPanelProps<V2CommandSearchItem> = {
isOpen: isV2CommandSearchOpen,
searchValue: v2CommandSearchValue,
activeIndex: v2CommandActiveIndex,
label: v2CommandSearchLabel,
placeholder: v2CommandSearchPlaceholder,
persistedFilter: v2PersistedSidebarFilter,
persistentFilterEnabled: v2CommandSearchPersistentFilterEnabled,
aiMode: v2CommandSearchAiMode,
objectMode: v2CommandSearchObjectMode,
flatItems: commandSearchFlatItems,
sections: {
goTo: filteredCommandSearchTreeItems,
ai: commandSearchAiItem,
actions: filteredCommandSearchActionItems,
recent: filteredCommandSearchRecentItems,
},
inputRef: commandSearchInputRef,
handlers: {
onSearchValueChange: handleV2CommandSearchValueChange,
onKeyDown: handleV2CommandSearchKeyDown,
onClose: closeV2CommandSearch,
onItemSelect: (item: V2CommandSearchItem) => runCommandSearchItem(item),
onItemHover: (key: string) => setV2CommandActiveIndex(commandSearchFlatItems.findIndex((entry) => entry.key === key)),
onTogglePersistentFilter: toggleV2CommandSearchPersistentFilter,
onResetFilter: resetV2SidebarFilter,
},
};
// V2 Connection Rail 子组件 props从原 renderV2ConnectionRail 抽出,保留所有原行为)
const v2ConnectionRailProps = {
labels: {
@@ -9511,7 +9446,7 @@ const Sidebar: React.FC<{
</div>
)}
</div>
{renderV2CommandSearchOverlay()}
<SidebarSearchPanel {...v2CommandSearchPanelProps} />
{contextMenu?.kind && typeof document !== 'undefined' && createPortal(
<div

View File

@@ -0,0 +1,167 @@
import React from 'react';
import { Input, Button, Switch, Tooltip } from 'antd';
import { SearchOutlined, ReloadOutlined, TableOutlined, RobotOutlined } from '@ant-design/icons';
import { noAutoCapInputProps } from '../../utils/inputAutoCap';
import { t } from '../../i18n';
// V2 Command Search 子组件(从 Sidebar.tsx 抽取)。
//
// 设计:把 renderV2CommandSearchRow/Section/Overlay 三个闭包函数合并为一个独立组件。
// Props 聚合为 5 个对象state + items + handlers + flags + labels避免 22+ 个独立 props。
//
// 注意:组件接收 V2CommandSearchItem[],类型由 Sidebar 主组件定义(含 React.ReactNode 字段),
// 这里用结构化类型 V2CommandSearchItemLike 代替,避免循环依赖。
export interface V2CommandSearchItemLike {
key: string;
kind: 'node' | 'action' | 'recent';
title: string;
meta?: string;
icon?: React.ReactNode;
shortcut?: string;
}
export interface SidebarSearchPanelProps<TItem extends V2CommandSearchItemLike = V2CommandSearchItemLike> {
isOpen: boolean;
searchValue: string;
activeIndex: number;
label: string;
placeholder: string;
persistedFilter: string;
persistentFilterEnabled: boolean;
aiMode: boolean;
objectMode: boolean;
flatItems: TItem[];
sections: {
goTo: TItem[];
ai: TItem[];
actions: TItem[];
recent: TItem[];
};
inputRef: React.Ref<any>;
handlers: {
onSearchValueChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onClose: () => void;
onItemSelect: (item: TItem) => void;
onItemHover: (key: string) => void;
onTogglePersistentFilter: (enabled: boolean) => void;
onResetFilter: () => void;
};
}
const SidebarSearchPanel = <TItem extends V2CommandSearchItemLike>({
isOpen,
searchValue,
activeIndex,
label,
placeholder,
persistedFilter,
persistentFilterEnabled,
aiMode,
objectMode,
flatItems,
sections,
inputRef,
handlers,
}: SidebarSearchPanelProps<TItem>) => {
if (!isOpen) return null;
const emptyCopy = aiMode
? '输入「?」后加问题,按 Enter 发送到 AI 面板。'
: objectMode
? '未找到匹配的表、视图或物化视图。'
: '未找到匹配项。可输入 @表名 只搜表对象,或输入 ?问题 让 AI 回答。';
const renderRow = (item: TItem, active: boolean) => (
<button
key={item.key}
type="button"
className={`gn-v2-command-row${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>
);
const renderSection = (title: string, items: TItem[]) => {
if (items.length === 0) return null;
return (
<section className="gn-v2-command-section">
<div className="gn-v2-command-section-title">{title}</div>
{items.map((item) =>
renderRow(item, flatItems[activeIndex]?.key === item.key),
)}
</section>
);
};
return (
<div className="gn-v2-command-backdrop" data-v2-command-search="true" onMouseDown={handlers.onClose}>
<div
className="gn-v2-command-palette"
role="dialog"
aria-modal="true"
aria-label={label}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="gn-v2-command-searchbar">
<SearchOutlined />
<Input
{...noAutoCapInputProps}
ref={inputRef}
variant="borderless"
value={searchValue}
onChange={(event) => handlers.onSearchValueChange(event.target.value)}
onKeyDown={handlers.onKeyDown}
placeholder={placeholder}
/>
<Tooltip title={t('sidebar.command_search.sync_to_filter_tooltip')}>
<span className="gn-v2-command-filter-switch" aria-label={t('sidebar.command_search.sync_to_filter_aria')}>
<Switch
size="small"
checked={persistentFilterEnabled}
onChange={handlers.onTogglePersistentFilter}
/>
</span>
</Tooltip>
<Tooltip title={persistedFilter ? t('sidebar.command_search.reset_filter') : t('sidebar.command_search.no_synced_filter')}>
<Button
size="small"
type="text"
icon={<ReloadOutlined />}
aria-label={t('sidebar.command_search.reset_filter')}
disabled={!persistedFilter}
onClick={handlers.onResetFilter}
/>
</Tooltip>
<kbd>esc</kbd>
</div>
<div className="gn-v2-command-list">
{renderSection('跳转 · GO TO', sections.goTo)}
{renderSection('AI · ASK', sections.ai)}
{renderSection('动作 · ACTIONS', sections.actions)}
{renderSection('近期查询 · RECENT', sections.recent)}
{flatItems.length === 0 ? (
<div className="gn-v2-command-empty">{emptyCopy}</div>
) : null}
</div>
<div className="gn-v2-command-footer">
<span><kbd></kbd><kbd></kbd></span>
<span><kbd></kbd></span>
<span><TableOutlined /> <kbd>@</kbd></span>
<span><RobotOutlined /> <kbd>?</kbd> AI</span>
</div>
</div>
</div>
);
};
export default SidebarSearchPanel;