🐛 fix(frontend): 修复 Redis 搜索匹配与输入交互体验

- Redis Key 搜索默认补全包含匹配并支持 ASCII 大小写不敏感
- Redis 标签页增加连接名与 host 摘要,区分同名 db 标签
- 抽取 inputAutoCap、redisSearchPattern、tabDisplay 共享工具并补充回归测试
- 覆盖连接配置、Redis 搜索、表设计、表概览和数据表筛选输入的自动纠正问题
- 在 macOS 文本输入面板关闭局部毛玻璃,修复输入法切换出现透明框
This commit is contained in:
Syngnat
2026-04-16 18:07:38 +08:00
parent d3a1c017da
commit af90936fcc
22 changed files with 541 additions and 122 deletions

View File

@@ -18,7 +18,7 @@ import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModa
import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModal';
import { DEFAULT_APPEARANCE, useStore } from './store';
import { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics';
@@ -812,7 +812,11 @@ function App() {
whiteSpace: 'nowrap',
fontSize: isSidebarCompact ? 13 : 14,
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, isSidebarCompact, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const disableLocalBackdropFilter = isMacLikePlatform();
const overlayTheme = useMemo(
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
[darkMode, disableLocalBackdropFilter],
);
const sidebarQuickActionBaseStyle = useMemo(() => ({
height: Math.max(34, Math.round(36 * effectiveUiScale)),

View File

@@ -4,7 +4,7 @@ import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutl
import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import {
getStoredSecretPlaceholder,
normalizeConnectionSecretErrorMessage,
@@ -12,6 +12,7 @@ import {
} from '../utils/connectionModalPresentation';
import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft';
import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn';
import { applyNoAutoCapAttributes, noAutoCapInputProps } from '../utils/inputAutoCap';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
@@ -24,21 +25,6 @@ const CONNECTION_MODAL_WIDTH = 960;
const CONNECTION_MODAL_BODY_HEIGHT = 620;
const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)';
const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)';
const noAutoCapInputProps = {
autoCapitalize: 'none' as const,
autoCorrect: 'off' as const,
spellCheck: false,
};
const applyNoAutoCapAttributes = (element: Element) => {
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
return;
}
element.setAttribute('autocapitalize', 'none');
element.setAttribute('autocorrect', 'off');
element.setAttribute('spellcheck', 'false');
};
type ConnectionSecretKey =
| 'primaryPassword'
| 'sshPassword'
@@ -177,6 +163,7 @@ const ConnectionModal: React.FC<{
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const disableLocalBackdropFilter = isMacLikePlatform();
const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single';
const mongoTopology = Form.useWatch('mongoTopology', form) || 'single';
const mongoSrv = Form.useWatch('mongoSrv', form) || false;
@@ -207,7 +194,10 @@ const ConnectionModal: React.FC<{
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff';
const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff';
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const overlayTheme = useMemo(
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
[darkMode, disableLocalBackdropFilter],
);
const tunnelSectionStyle: React.CSSProperties = {
padding: '12px',
@@ -3233,5 +3223,3 @@ const ConnectionModal: React.FC<{
export default ConnectionModal;

View File

@@ -50,6 +50,7 @@ import {
} from './dataGridCopyInsert';
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -2234,6 +2235,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// Filter State
const [filterConditions, setFilterConditions] = useState<GridFilterCondition[]>([]);
const [nextFilterId, setNextFilterId] = useState(1);
const filterPanelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const nextConditions = normalizeGridFilterConditions(appliedFilterConditions);
@@ -2242,6 +2244,30 @@ const DataGrid: React.FC<DataGridProps> = ({
setNextFilterId(Math.max(1, maxId + 1));
}, [appliedFilterConditions, normalizeGridFilterConditions]);
useEffect(() => {
if (!showFilter) {
return;
}
const root = filterPanelRef.current;
if (!root) {
return;
}
const apply = () => {
applyNoAutoCapAttributesWithin(root);
};
apply();
if (typeof MutationObserver === 'undefined') {
return;
}
const observer = new MutationObserver(() => {
apply();
});
observer.observe(root, { childList: true, subtree: true });
return () => {
observer.disconnect();
};
}, [showFilter]);
const selectedRowKeysRef = useRef(selectedRowKeys);
const displayDataRef = useRef<any[]>([]);
@@ -5135,7 +5161,7 @@ const DataGrid: React.FC<DataGridProps> = ({
</div>
{showFilter && (
<div style={{
<div ref={filterPanelRef} style={{
padding: `${filterTopPadding}px ${panelPaddingX}px ${panelPaddingY}px ${panelPaddingX}px`,
background: 'transparent',
boxSizing: 'border-box',
@@ -5184,6 +5210,7 @@ const DataGrid: React.FC<DataGridProps> = ({
{cond.op === 'CUSTOM' ? (
<Input.TextArea
{...noAutoCapInputProps}
style={{ flex: 1 }}
autoSize={{ minRows: 1, maxRows: 4 }}
value={cond.value}
@@ -5192,6 +5219,7 @@ const DataGrid: React.FC<DataGridProps> = ({
/>
) : isListOp(cond.op) ? (
<Input.TextArea
{...noAutoCapInputProps}
style={{ flex: 1 }}
autoSize={{ minRows: 1, maxRows: 4 }}
value={cond.value}
@@ -5201,12 +5229,14 @@ const DataGrid: React.FC<DataGridProps> = ({
) : isBetweenOp(cond.op) ? (
<>
<Input
{...noAutoCapInputProps}
style={{ width: 220 }}
value={cond.value}
onChange={e => updateFilter(cond.id, 'value', e.target.value)}
placeholder="开始值"
/>
<Input
{...noAutoCapInputProps}
style={{ width: 220 }}
value={cond.value2 || ''}
onChange={e => updateFilter(cond.id, 'value2', e.target.value)}
@@ -5214,9 +5244,10 @@ const DataGrid: React.FC<DataGridProps> = ({
/>
</>
) : isNoValueOp(cond.op) ? (
<Input style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
<Input {...noAutoCapInputProps} style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
) : (
<Input
{...noAutoCapInputProps}
style={{ width: 280 }}
value={cond.value}
onChange={e => updateFilter(cond.id, 'value', e.target.value)}

View File

@@ -5,7 +5,7 @@ import { useStore } from '../store';
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
import { SavedConnection } from '../types';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
@@ -190,6 +190,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const darkMode = themeMode === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const disableLocalBackdropFilter = isMacLikePlatform();
// Step 1: Config
const [sourceConnId, setSourceConnId] = useState<string>('');
@@ -630,8 +631,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.36)' : '0 18px 44px rgba(15,23,42,0.14)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
backdropFilter: resolveTextInputSafeBackdropFilter(darkMode ? 'blur(18px)' : 'none', disableLocalBackdropFilter),
}), [darkMode, disableLocalBackdropFilter]);
const shellCardStyle = useMemo<React.CSSProperties>(() => ({
borderRadius: 18,

View File

@@ -6,6 +6,7 @@ import { quoteIdentPart, escapeLiteral } from '../utils/sql';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isMacLikePlatform } from '../utils/appearance';
interface FindInDatabaseModalProps {
open: boolean;
@@ -67,14 +68,15 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const disableLocalBackdropFilter = isMacLikePlatform();
const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]);
const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]);
const wt = useMemo(() => {
const isDark = theme === 'dark';
return buildOverlayWorkbenchTheme(isDark);
}, [theme]);
return buildOverlayWorkbenchTheme(isDark, { disableBackdropFilter: disableLocalBackdropFilter });
}, [disableLocalBackdropFilter, theme]);
const buildConfig = useCallback(() => {
if (!conn) return null;

View File

@@ -6,7 +6,14 @@ import { useStore } from '../store';
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
import Editor from '@monaco-editor/react';
import type { DataNode } from 'antd/es/tree';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import {
blurToFilter,
isMacLikePlatform,
normalizeBlurForPlatform,
normalizeOpacityForPlatform,
resolveAppearanceValues,
resolveTextInputSafeBackdropFilter,
} from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
applyRenamedRedisKeyState,
@@ -19,6 +26,8 @@ import {
type RedisTreeDataNode,
} from './redisViewerTree';
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput } from '../utils/redisSearchPattern';
const { Search } = Input;
@@ -283,8 +292,16 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const blur = normalizeBlurForPlatform(resolvedAppearance.blur);
const disableLocalBackdropFilter = isMacLikePlatform();
const connection = connections.find(c => c.id === connectionId);
const workbenchTheme = useMemo(() => buildRedisWorkbenchTheme({ darkMode, opacity, blur }), [blur, darkMode, opacity]);
const workbenchTheme = useMemo(
() => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }),
[blur, darkMode, disableLocalBackdropFilter, opacity],
);
const workbenchBackdropFilter = useMemo(
() => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter),
[blur, disableLocalBackdropFilter],
);
const keyAccentColor = workbenchTheme.accent;
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
const valueToolbarBg = workbenchTheme.panelBgStrong;
@@ -293,6 +310,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
const [loading, setLoading] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [searchPattern, setSearchPattern] = useState('*');
const [cursor, setCursor] = useState<string>('0');
const [hasMore, setHasMore] = useState(false);
@@ -467,13 +485,29 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
useEffect(() => {
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
}, [redisDB]);
}, [loadKeys, redisDB]);
const executeSearch = useCallback((value: string) => {
const normalized = normalizeRedisSearchInput(value);
setSearchInput(normalized.keyword);
setSearchPattern(normalized.pattern);
setCursor('0');
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
}, [loadKeys]);
const handleSearch = (value: string) => {
const pattern = value.trim() || '*';
setSearchPattern(pattern);
executeSearch(value);
};
const handleSearchInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const normalized = normalizeRedisSearchDraftChange(event.target.value);
setSearchInput(normalized.keyword);
if (!normalized.shouldSearchImmediately) {
return;
}
setSearchPattern(normalized.pattern);
setCursor('0');
loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false));
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
};
const handleLoadMore = () => {
@@ -1214,9 +1248,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
title: '添加字段',
content: (
<Form id="add-hash-field-form" layout="vertical">
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
<Input id="new-hash-field" />
</Form.Item>
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
<Input id="new-hash-field" {...noAutoCapInputProps} />
</Form.Item>
<Form.Item label="值" name="value" rules={[{ required: true }]}>
<Input.TextArea id="new-hash-value" rows={4} />
</Form.Item>
@@ -1888,7 +1922,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<div>
<div style={{ marginBottom: 8 }}>
<label>ID *</label>
<Input id="new-stream-id" placeholder="例如: * 或 1723110000000-0" />
<Input id="new-stream-id" {...noAutoCapInputProps} placeholder="例如: * 或 1723110000000-0" />
</div>
<div>
<label> JSON</label>
@@ -2050,7 +2084,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}
return (
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: blurToFilter(blur), WebkitBackdropFilter: blurToFilter(blur) }}>
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
{/* Left: Key List */}
<div ref={leftPanelRef} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
<div style={{ ...workbenchCardStyle, padding: 12 }}>
@@ -2063,9 +2097,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</div>
<Space.Compact style={{ width: '100%' }}>
<Search
placeholder="搜索 Key (支持 * 通配符)"
defaultValue="*"
{...noAutoCapInputProps}
placeholder="搜索 Key"
value={searchInput}
onChange={handleSearchInputChange}
onSearch={handleSearch}
allowClear
enterButton={<SearchOutlined />}
/>
</Space.Compact>
@@ -2177,7 +2214,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
>
<Form form={newKeyForm} layout="vertical" initialValues={{ ttl: -1 }}>
<Form.Item name="key" label="Key" rules={[{ required: true, message: '请输入 Key' }]}>
<Input placeholder="key name" />
<Input {...noAutoCapInputProps} placeholder="key name" />
</Form.Item>
<Form.Item name="value" label="值" rules={[{ required: true, message: '请输入值' }]}>
<Input.TextArea rows={4} placeholder="value" />
@@ -2207,7 +2244,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
rules={[{ required: true, message: '请输入新的 Key 名称' }]}
extra={renameTargetKey ? `原始 Key${renameTargetKey}` : undefined}
>
<Input placeholder="new:key:name" />
<Input {...noAutoCapInputProps} placeholder="new:key:name" />
</Form.Item>
</Form>
</Modal>

View File

@@ -41,11 +41,13 @@ import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import FindInDatabaseModal from './FindInDatabaseModal';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
const { Search } = Input;
@@ -138,6 +140,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const disableLocalBackdropFilter = isMacLikePlatform();
const autoFetchVisible = useAutoFetchVisibility();
const [treeData, setTreeData] = useState<TreeNode[]>([]);
@@ -151,7 +154,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const bgMain = getBg('#141414');
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const overlayTheme = useMemo(
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
[darkMode, disableLocalBackdropFilter],
);
const modalPanelStyle = useMemo(() => ({
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
@@ -2858,51 +2864,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
);
}, [darkMode, overlayTheme, searchScopes]);
const parseHostOnlyToken = (value: unknown): string[] => {
const raw = String(value || '').trim();
if (!raw) {
return [];
}
let text = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
if (text.includes('/')) {
text = text.split('/')[0];
}
if (text.includes('?')) {
text = text.split('?')[0];
}
if (text.includes('@')) {
text = text.split('@').pop() || '';
}
return text
.split(',')
.map((entry) => {
const token = entry.trim();
if (!token) return '';
if (token.startsWith('[')) {
const rightBracketIndex = token.indexOf(']');
if (rightBracketIndex > 0) {
return token.slice(0, rightBracketIndex + 1).toLowerCase();
}
}
const colonIndex = token.lastIndexOf(':');
if (colonIndex > 0) {
return token.slice(0, colonIndex).toLowerCase();
}
return token.toLowerCase();
})
.filter(Boolean);
};
const getConnectionHostSearchText = (node: TreeNode): string => {
if (node.type !== 'connection') return '';
const config = node.dataRef?.config || {};
const hostTokens = [
...parseHostOnlyToken(config.host),
...(Array.isArray(config.hosts) ? config.hosts.flatMap((entry: string) => parseHostOnlyToken(entry)) : []),
...parseHostOnlyToken(config.uri),
];
const uniqueHosts = Array.from(new Set(hostTokens));
return uniqueHosts.join(' ');
return resolveConnectionHostTokens(config).join(' ');
};
const getConnectionNameSearchText = (node: TreeNode): string => {
@@ -3110,7 +3075,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onClick: () => {
addTab({
id: `redis-cmd-${node.key}-${Date.now()}`,
title: `命令 - ${node.title}`,
title: '命令 - db0',
type: 'redis-command',
connectionId: node.key,
redisDB: 0
@@ -3124,7 +3089,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onClick: () => {
addTab({
id: `redis-monitor-${node.key}-${Date.now()}`,
title: `监控: ${node.title}`,
title: '监控 - db0',
type: 'redis-monitor',
connectionId: node.key,
redisDB: 0
@@ -3386,7 +3351,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onClick: () => {
addTab({
id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`,
title: `监控: ${connections.find(c => c.id === id)?.name || id}`,
title: `监控 - db${redisDB}`,
type: 'redis-monitor',
connectionId: id,
redisDB: redisDB
@@ -3835,6 +3800,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
<Input
{...noAutoCapInputProps}
ref={searchInputRef}
placeholder="搜索..."
onChange={onSearch}
@@ -4026,7 +3992,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
>
<Form form={createDbForm} layout="vertical">
<Form.Item name="name" label="数据库名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input />
<Input {...noAutoCapInputProps} />
</Form.Item>
{/* Charset option could be added here */}
</Form>
@@ -4044,7 +4010,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
>
<Form form={renameDbForm} layout="vertical">
<Form.Item name="newName" label="新数据库名称" rules={[{ required: true, message: '请输入新数据库名称' }]}>
<Input />
<Input {...noAutoCapInputProps} />
</Form.Item>
</Form>
</Modal>
@@ -4061,7 +4027,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
>
<Form form={renameTableForm} layout="vertical">
<Form.Item name="newName" label="新表名" rules={[{ required: true, message: '请输入新表名' }]}>
<Input />
<Input {...noAutoCapInputProps} />
</Form.Item>
</Form>
</Modal>
@@ -4078,7 +4044,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
>
<Form form={renameViewForm} layout="vertical">
<Form.Item name="newName" label="新视图名" rules={[{ required: true, message: '请输入新视图名' }]}>
<Input />
<Input {...noAutoCapInputProps} />
</Form.Item>
</Form>
</Modal>

View File

@@ -17,24 +17,7 @@ import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import type { TabData } from '../types';
const detectConnectionEnvLabel = (connectionName: string): string | null => {
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
if (tokens.includes('uat')) return 'UAT';
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
if (tokens.includes('sit')) return 'SIT';
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
return null;
};
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title;
if (!connectionName) return tab.title;
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
};
import { buildTabDisplayTitle } from '../utils/tabDisplay';
type SortableTabLabelProps = {
displayTitle: string;
@@ -50,7 +33,7 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
<span
className="tab-dnd-label"
onContextMenu={(e) => e.preventDefault()}
title="拖拽调整标签顺序"
title={displayTitle}
>
{displayTitle}
</span>
@@ -198,8 +181,8 @@ const TabManager: React.FC = () => {
);
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayTitle = buildTabDisplayTitle(tab, connection);
const tabIsActive = tab.id === activeTabId;
let content;
if (tab.type === 'query') {

View File

@@ -11,6 +11,7 @@ import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, D
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
interface EditableColumn extends ColumnDefinition {
_key: string;
@@ -546,7 +547,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
key: 'name',
width: 180,
render: (text: string, record: EditableColumn) => readOnly ? text : (
<Input value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
<Input {...noAutoCapInputProps} value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
)
},
{
@@ -2492,6 +2493,7 @@ END;`;
{isNewTable && (
<>
<Input
{...noAutoCapInputProps}
placeholder="请输入表名"
value={newTableName}
onChange={e => setNewTableName(e.target.value)}
@@ -2805,6 +2807,7 @@ END;`;
{selectedColumns.length}
</div>
<Input
{...noAutoCapInputProps}
placeholder="请输入目标表名"
value={copyTableName}
onChange={e => setCopyTableName(e.target.value)}
@@ -2865,6 +2868,7 @@ END;`;
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
{...noAutoCapInputProps}
placeholder={indexForm.kind === 'PRIMARY' ? '主键索引固定名称PRIMARY' : '索引名(例如 idx_user_name'}
value={indexForm.name}
onChange={(e) => setIndexForm(prev => ({ ...prev, name: e.target.value }))}
@@ -2934,6 +2938,7 @@ END;`;
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
{...noAutoCapInputProps}
placeholder="外键约束名(例如 fk_order_user"
value={foreignKeyForm.constraintName}
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))}
@@ -2949,6 +2954,7 @@ END;`;
style={{ width: '100%' }}
/>
<Input
{...noAutoCapInputProps}
placeholder="参考表(支持 db.table"
value={foreignKeyForm.refTableName}
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))}

View File

@@ -6,6 +6,7 @@ import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from
import type { TabData } from '../types';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
interface TableOverviewProps {
@@ -344,6 +345,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
title: '重命名表',
content: (
<Input
{...noAutoCapInputProps}
defaultValue={tableName}
onChange={e => { newName = e.target.value; }}
placeholder="输入新表名"
@@ -417,6 +419,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
</span>
<div style={{ flex: 1 }} />
<Input
{...noAutoCapInputProps}
placeholder="搜索表名或注释..."
prefix={<SearchOutlined style={{ color: textMuted }} />}
value={searchText}

View File

@@ -25,4 +25,9 @@ describe('buildRedisWorkbenchTheme', () => {
expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg);
expect(lightTheme.backdropFilter).toBe('none');
});
it('can disable redis workbench blur for macOS text-entry compatibility', () => {
const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14, disableBackdropFilter: true });
expect(darkTheme.backdropFilter).toBe('none');
});
});

View File

@@ -1,7 +1,10 @@
import { resolveTextInputSafeBackdropFilter } from '../utils/appearance';
type RedisWorkbenchThemeInput = {
darkMode: boolean;
opacity: number;
blur: number;
disableBackdropFilter?: boolean;
};
type RedisWorkbenchTheme = {
@@ -43,10 +46,15 @@ export const buildRedisWorkbenchTheme = ({
darkMode,
opacity,
blur,
disableBackdropFilter,
}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => {
const normalizedOpacity = clamp(opacity, 0.1, 1);
const normalizedBlur = Math.max(0, Math.round(blur));
const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0;
const backdropFilter = resolveTextInputSafeBackdropFilter(
normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
disableBackdropFilter ?? false,
);
if (darkMode) {
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
@@ -84,7 +92,7 @@ export const buildRedisWorkbenchTheme = ({
treeSelectedBorder: 'rgba(246, 196, 83, 0.24)',
divider: 'rgba(255, 255, 255, 0.07)',
shadow: '0 20px 48px rgba(0, 0, 0, 0.26)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
backdropFilter,
};
}
@@ -122,7 +130,7 @@ export const buildRedisWorkbenchTheme = ({
treeSelectedBorder: 'rgba(22, 119, 255, 0.18)',
divider: 'rgba(15, 23, 42, 0.08)',
shadow: '0 22px 52px rgba(15, 23, 42, 0.08)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
backdropFilter,
};
};

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
import {
blurToFilter,
normalizeBlurForPlatform,
normalizeOpacityForPlatform,
resolveAppearanceValues,
resolveTextInputSafeBackdropFilter,
} from './appearance';
describe('appearance helpers', () => {
it('falls back to opaque non-blurred appearance when disabled', () => {
@@ -20,4 +26,10 @@ describe('appearance helpers', () => {
expect(blurToFilter(0)).toBeUndefined();
expect(blurToFilter(8)).toBe('blur(8px)');
});
it('disables local backdrop blur for text-entry surfaces on macOS', () => {
expect(resolveTextInputSafeBackdropFilter('blur(18px)', true)).toBe('none');
expect(resolveTextInputSafeBackdropFilter('blur(18px)', false)).toBe('blur(18px)');
expect(resolveTextInputSafeBackdropFilter(undefined, true)).toBe('none');
});
});

View File

@@ -80,3 +80,16 @@ export const normalizeBlurForPlatform = (blur: number | undefined): number => {
export const blurToFilter = (blur: number): string | undefined => {
return blur > 0 ? `blur(${blur}px)` : undefined;
};
// macOS WebView 下,文本输入区域祖先节点的 backdrop-filter 会和输入法候选/切换浮层叠加,
// 造成额外的透明框。这里允许交互面板按平台降级为非模糊背景。
export const resolveTextInputSafeBackdropFilter = (
backdropFilter: string | undefined,
disableForMacLike: boolean = isMacLikePlatform(),
): string => {
const normalized = String(backdropFilter || '').trim();
if (!normalized || normalized === 'none') {
return 'none';
}
return disableForMacLike ? 'none' : normalized;
};

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { applyNoAutoCapAttributes, applyNoAutoCapAttributesWithin, noAutoCapInputProps } from './inputAutoCap';
describe('inputAutoCap', () => {
it('exports input props that disable auto capitalization and correction', () => {
expect(noAutoCapInputProps).toEqual({
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
});
});
it('applies lowercase DOM attributes to inputs and textareas', () => {
const inputAttributes: Record<string, string> = {};
const textareaAttributes: Record<string, string> = {};
const input = {
tagName: 'INPUT',
setAttribute: (key: string, value: string) => {
inputAttributes[key] = value;
},
} as unknown as Element;
const textarea = {
tagName: 'TEXTAREA',
setAttribute: (key: string, value: string) => {
textareaAttributes[key] = value;
},
} as unknown as Element;
applyNoAutoCapAttributes(input);
applyNoAutoCapAttributes(textarea);
expect(inputAttributes.autocapitalize).toBe('none');
expect(inputAttributes.autocorrect).toBe('off');
expect(inputAttributes.spellcheck).toBe('false');
expect(textareaAttributes.autocapitalize).toBe('none');
expect(textareaAttributes.autocorrect).toBe('off');
expect(textareaAttributes.spellcheck).toBe('false');
});
it('applies no-auto-cap attributes to all nested inputs and textareas within a container', () => {
const inputAttributes: Record<string, string> = {};
const textareaAttributes: Record<string, string> = {};
const input = {
tagName: 'INPUT',
setAttribute: (key: string, value: string) => {
inputAttributes[key] = value;
},
} as unknown as Element;
const textarea = {
tagName: 'TEXTAREA',
setAttribute: (key: string, value: string) => {
textareaAttributes[key] = value;
},
} as unknown as Element;
const root = {
querySelectorAll: (selector: string) => {
expect(selector).toBe('input, textarea');
return [input, textarea];
},
} as unknown as ParentNode;
applyNoAutoCapAttributesWithin(root);
expect(inputAttributes.autocapitalize).toBe('none');
expect(inputAttributes.autocorrect).toBe('off');
expect(textareaAttributes.autocapitalize).toBe('none');
expect(textareaAttributes.autocorrect).toBe('off');
});
});

View File

@@ -0,0 +1,26 @@
export const noAutoCapInputProps = {
autoCapitalize: 'none' as const,
autoCorrect: 'off' as const,
spellCheck: false,
};
export const applyNoAutoCapAttributes = (element: Element) => {
const tagName = String((element as Element | null)?.tagName || '').toUpperCase();
if (tagName !== 'INPUT' && tagName !== 'TEXTAREA') {
return;
}
element.setAttribute('autocapitalize', 'none');
element.setAttribute('autocorrect', 'off');
element.setAttribute('spellcheck', 'false');
};
export const applyNoAutoCapAttributesWithin = (root: ParentNode | null | undefined) => {
if (!root || typeof root.querySelectorAll !== 'function') {
return;
}
root.querySelectorAll('input, textarea').forEach((element) => {
applyNoAutoCapAttributes(element);
});
};

View File

@@ -18,4 +18,9 @@ describe('buildOverlayWorkbenchTheme', () => {
expect(lightTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/);
expect(lightTheme.iconColor).toBe('#1677ff');
});
it('can disable shell blur for macOS text-entry compatibility', () => {
const darkTheme = buildOverlayWorkbenchTheme(true, { disableBackdropFilter: true });
expect(darkTheme.shellBackdropFilter).toBe('none');
});
});

View File

@@ -1,3 +1,5 @@
import { resolveTextInputSafeBackdropFilter } from './appearance';
type OverlayWorkbenchTheme = {
isDark: boolean;
shellBg: string;
@@ -16,14 +18,22 @@ type OverlayWorkbenchTheme = {
divider: string;
};
export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchTheme => {
export const buildOverlayWorkbenchTheme = (
darkMode: boolean,
options?: { disableBackdropFilter?: boolean },
): OverlayWorkbenchTheme => {
const shellBackdropFilter = resolveTextInputSafeBackdropFilter(
darkMode ? 'blur(18px)' : 'none',
options?.disableBackdropFilter ?? false,
);
if (darkMode) {
return {
isDark: true,
shellBg: 'linear-gradient(180deg, rgba(15, 15, 17, 0.96) 0%, rgba(11, 11, 13, 0.98) 100%)',
shellBorder: '1px solid rgba(255,255,255,0.08)',
shellShadow: '0 24px 56px rgba(0,0,0,0.34)',
shellBackdropFilter: 'blur(18px)',
shellBackdropFilter,
sectionBg: 'rgba(255,255,255,0.03)',
sectionBorder: '1px solid rgba(255,255,255,0.08)',
mutedText: 'rgba(255,255,255,0.5)',
@@ -42,7 +52,7 @@ export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchT
shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
shellBorder: '1px solid rgba(16,24,40,0.08)',
shellShadow: '0 18px 42px rgba(15,23,42,0.12)',
shellBackdropFilter: 'none',
shellBackdropFilter,
sectionBg: 'rgba(255,255,255,0.84)',
sectionBorder: '1px solid rgba(16,24,40,0.08)',
mutedText: 'rgba(16,24,40,0.55)',

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput } from './redisSearchPattern';
describe('normalizeRedisSearchInput', () => {
it('returns wildcard for empty input', () => {
expect(normalizeRedisSearchInput('')).toEqual({
keyword: '',
pattern: '*',
});
});
it('wraps plain keywords with wildcard for contains matching', () => {
expect(normalizeRedisSearchInput('order')).toEqual({
keyword: 'order',
pattern: '*[oO][rR][dD][eE][rR]*',
});
});
it('builds ascii case-insensitive patterns for letter keywords', () => {
expect(normalizeRedisSearchInput('agent')).toEqual({
keyword: 'agent',
pattern: '*[aA][gG][eE][nN][tT]*',
});
});
it('escapes redis glob special characters as literals', () => {
expect(normalizeRedisSearchInput('user:*:[id]?')).toEqual({
keyword: 'user:*:[id]?',
pattern: '*[uU][sS][eE][rR]:\\*:\\[[iI][dD]\\]\\?*',
});
});
it('marks empty draft changes for immediate reset search', () => {
expect(normalizeRedisSearchDraftChange('')).toEqual({
keyword: '',
pattern: '*',
shouldSearchImmediately: true,
});
});
});

View File

@@ -0,0 +1,41 @@
const REDIS_GLOB_SPECIAL_CHARS = /([*?\[\]\\])/g;
const ASCII_LETTER = /^[A-Za-z]$/;
const escapeRedisGlobLiteral = (value: string): string => {
return value.replace(REDIS_GLOB_SPECIAL_CHARS, '\\$1');
};
const toCaseInsensitiveRedisGlobLiteral = (value: string): string => {
return Array.from(value).map((char) => {
if (!ASCII_LETTER.test(char)) {
return escapeRedisGlobLiteral(char);
}
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return `[${lower}${upper}]`;
}).join('');
};
export const normalizeRedisSearchInput = (rawValue: string): { keyword: string; pattern: string } => {
const keyword = String(rawValue || '').trim();
if (!keyword) {
return { keyword: '', pattern: '*' };
}
return {
keyword,
pattern: `*${toCaseInsensitiveRedisGlobLiteral(keyword)}*`,
};
};
export const normalizeRedisSearchDraftChange = (rawValue: string): {
keyword: string;
pattern: string;
shouldSearchImmediately: boolean;
} => {
const normalized = normalizeRedisSearchInput(rawValue);
return {
...normalized,
shouldSearchImmediately: normalized.keyword === '',
};
};

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import type { SavedConnection, TabData } from '../types';
import { buildTabDisplayTitle, resolveConnectionHostSummary } from './tabDisplay';
const redisConnection: SavedConnection = {
id: 'redis-1',
name: '订单缓存',
config: {
type: 'redis',
host: '10.10.0.12',
port: 6379,
user: '',
database: '',
hosts: ['10.10.0.13:6379', '10.10.0.14:6379'],
},
};
describe('tabDisplay', () => {
it('builds compact host summary for multi-host redis connections', () => {
expect(resolveConnectionHostSummary(redisConnection.config)).toBe('10.10.0.12 +2');
});
it('adds connection and host identity to redis key tabs', () => {
const redisKeysTab: TabData = {
id: 'redis-keys-redis-1-db0',
title: 'db0',
type: 'redis-keys',
connectionId: 'redis-1',
redisDB: 0,
};
expect(buildTabDisplayTitle(redisKeysTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] db0');
});
it('normalizes redis command and monitor tabs to db-scoped labels', () => {
const commandTab: TabData = {
id: 'cmd-1',
title: '命令 - db1',
type: 'redis-command',
connectionId: 'redis-1',
redisDB: 1,
};
const monitorTab: TabData = {
id: 'monitor-1',
title: '监控: 订单缓存',
type: 'redis-monitor',
connectionId: 'redis-1',
redisDB: 1,
};
expect(buildTabDisplayTitle(commandTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] 命令 - db1');
expect(buildTabDisplayTitle(monitorTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] 监控 - db1');
});
it('keeps table tabs on the existing prefix strategy', () => {
const tableTab: TabData = {
id: 'table-1',
title: 'orders',
type: 'table',
connectionId: 'redis-1',
dbName: 'app',
tableName: 'orders',
};
expect(buildTabDisplayTitle(tableTab, redisConnection)).toBe('[订单缓存] orders');
});
});

View File

@@ -0,0 +1,99 @@
import type { ConnectionConfig, SavedConnection, TabData } from '../types';
export const detectConnectionEnvLabel = (connectionName: string): string | null => {
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
if (tokens.includes('uat')) return 'UAT';
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
if (tokens.includes('sit')) return 'SIT';
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
return null;
};
const parseHostOnlyToken = (value: unknown): string[] => {
const raw = String(value || '').trim();
if (!raw) {
return [];
}
let text = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
if (text.includes('/')) {
text = text.split('/')[0];
}
if (text.includes('?')) {
text = text.split('?')[0];
}
if (text.includes('@')) {
text = text.split('@').pop() || '';
}
return text
.split(',')
.map((entry) => {
const token = entry.trim();
if (!token) return '';
if (token.startsWith('[')) {
const rightBracketIndex = token.indexOf(']');
if (rightBracketIndex > 0) {
return token.slice(0, rightBracketIndex + 1).toLowerCase();
}
}
const colonIndex = token.lastIndexOf(':');
if (colonIndex > 0) {
return token.slice(0, colonIndex).toLowerCase();
}
return token.toLowerCase();
})
.filter(Boolean);
};
export const resolveConnectionHostTokens = (config?: ConnectionConfig): string[] => {
if (!config) {
return [];
}
return Array.from(new Set([
...parseHostOnlyToken(config.host),
...(Array.isArray(config.hosts) ? config.hosts.flatMap((entry) => parseHostOnlyToken(entry)) : []),
...parseHostOnlyToken(config.uri),
]));
};
export const resolveConnectionHostSummary = (config?: ConnectionConfig): string => {
const hosts = resolveConnectionHostTokens(config);
if (hosts.length === 0) return '';
if (hosts.length === 1) return hosts[0];
return `${hosts[0]} +${hosts.length - 1}`;
};
const isRedisTab = (tab: TabData): boolean => {
return tab.type === 'redis-keys' || tab.type === 'redis-command' || tab.type === 'redis-monitor';
};
const buildRedisBaseTitle = (tab: TabData): string => {
const dbLabel = `db${tab.redisDB ?? 0}`;
if (tab.type === 'redis-command') return `命令 - ${dbLabel}`;
if (tab.type === 'redis-monitor') return `监控 - ${dbLabel}`;
return dbLabel;
};
export const buildTabDisplayTitle = (tab: TabData, connection?: SavedConnection): string => {
const connectionName = String(connection?.name || '').trim();
if (isRedisTab(tab)) {
const hostSummary = resolveConnectionHostSummary(connection?.config);
const identity = [connectionName, hostSummary].filter(Boolean).join(' | ');
return identity ? `[${identity}] ${buildRedisBaseTitle(tab)}` : buildRedisBaseTitle(tab);
}
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') {
return tab.title;
}
if (!connectionName) {
return tab.title;
}
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
};