🐛 fix(sidebar): 修复新版左侧分组与 Host 拖拽排序

- 新增 sidebarRootOrder 持久化左侧根节点顺序
- 支持分组与未分组 Host 在新版左侧根层混排
- 统一 v2 rail 与树视图拖拽写回根层排序
- 拖拽期间抑制误选中与 Host 误切换
- 补充 Sidebar 与 store 拖拽排序回归测试
This commit is contained in:
Syngnat
2026-05-28 22:34:03 +08:00
parent b7c5db181a
commit e4438780fe
4 changed files with 1529 additions and 161 deletions

View File

@@ -9,16 +9,27 @@ import Sidebar, {
filterV2ExplorerTreeByKind,
getV2RailConnectionGroupBadgeText,
hasSidebarLazyChildren,
normalizeSidebarTreeRelativeDropPosition,
parseV2CommandSearchQuery,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarTagDropInsertBefore,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarDropInsertBefore,
resolveSidebarNodeConnectionId,
resolveV2ActiveConnectionId,
isSidebarTablePinned,
resolveSidebarTableNameForCopy,
shouldClearSidebarActiveContextOnEmptySelect,
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
shouldLoadSidebarNodeOnExpand,
sortSidebarTableEntries,
} from './Sidebar';
import { buildSidebarTablePinKey } from '../store';
import {
buildSidebarRootConnectionToken,
buildSidebarRootTagToken,
buildSidebarTablePinKey,
} from '../store';
import {
DEFAULT_SHORTCUT_OPTIONS,
cloneShortcutOptions,
@@ -58,6 +69,36 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock('../store', () => ({
buildSidebarRootConnectionToken: (connectionId: string) => `connection:${connectionId.trim()}`,
buildSidebarRootTagToken: (tagId: string) => `tag:${tagId.trim()}`,
resolveSidebarRootOrderTokens: (
sidebarRootOrder: unknown,
connectionTags: Array<{ id: string; connectionIds: string[] }>,
connections: Array<{ id: string }>,
) => {
const groupedConnectionIds = new Set<string>();
connectionTags.forEach((tag) => tag.connectionIds.forEach((id) => groupedConnectionIds.add(id)));
const fallback = [
...connectionTags.map((tag) => `tag:${tag.id}`),
...connections
.filter((conn) => !groupedConnectionIds.has(conn.id))
.map((conn) => `connection:${conn.id}`),
];
const valid = new Set(fallback);
const normalized = Array.isArray(sidebarRootOrder)
? sidebarRootOrder
.map((item) => String(item ?? '').trim())
.filter((item) => valid.has(item))
: [];
const seen = new Set<string>();
const result: string[] = [];
[...normalized, ...fallback].forEach((token) => {
if (!token || seen.has(token)) return;
seen.add(token);
result.push(token);
});
return result;
},
buildSidebarTablePinKey: (
connectionId: string,
dbName: string,
@@ -83,11 +124,14 @@ vi.mock('../store', () => ({
setActiveContext: mocks.noop,
removeConnection: mocks.noop,
connectionTags: mocks.state.connectionTags,
sidebarRootOrder: [],
addConnectionTag: mocks.noop,
updateConnectionTag: mocks.noop,
removeConnectionTag: mocks.noop,
moveConnectionToTag: mocks.noop,
reorderConnections: mocks.noop,
reorderTags: mocks.noop,
reorderSidebarRoot: mocks.noop,
closeTabsByConnection: mocks.noop,
closeTabsByDatabase: mocks.noop,
theme: 'light',
@@ -180,7 +224,7 @@ describe('Sidebar locate toolbar', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain('if (hasSidebarLazyChildren(children)) return;');
expect(source).toContain('if (info?.expanded && shouldLoadSidebarNodeOnExpand(info.node))');
expect(source).toContain('if (!shouldSkipSidebarLoadOnExpandWhileDragging(isTreeDragging, info))');
expect(source).toContain('if (shouldLoadSidebarNodeOnExpand(node))');
});
@@ -237,6 +281,15 @@ describe('Sidebar locate toolbar', () => {
})).toBe('dev240');
});
it('keeps the v2 active host empty when nothing is selected', () => {
expect(resolveV2ActiveConnectionId({
activeContextConnectionId: '',
activeTabConnectionId: '',
selectedKeys: [],
connectionIds: ['local', 'dev240', 'dev241'],
})).toBe('');
});
it('does not clear v2 active context when rc-tree emits an empty deselect', () => {
expect(shouldClearSidebarActiveContextOnEmptySelect(true)).toBe(false);
expect(shouldClearSidebarActiveContextOnEmptySelect(false)).toBe(true);
@@ -249,20 +302,40 @@ describe('Sidebar locate toolbar', () => {
{ id: 'local', name: 'local', config: { type: 'mysql', host: 'localhost' } },
] as any[];
const groups = buildV2RailConnectionGroups(connections, [{
id: 'prod',
name: '生产环境',
connectionIds: ['dev241', 'missing', 'dev240'],
}]);
const groups = buildV2RailConnectionGroups(
connections,
[{
id: 'prod',
name: '生产环境',
connectionIds: ['dev241', 'missing', 'dev240'],
}],
[
buildSidebarRootConnectionToken('local'),
buildSidebarRootTagToken('prod'),
],
);
expect(groups.map((group) => ({
id: group.id,
name: group.name,
isUngrouped: group.isUngrouped,
rootToken: group.rootToken,
connectionIds: group.connections.map((conn) => conn.id),
}))).toEqual([
{ id: 'prod', name: '生产环境', isUngrouped: undefined, connectionIds: ['dev241', 'dev240'] },
{ id: '__gonavi-v2-ungrouped-connections__', name: '未分组', isUngrouped: true, connectionIds: ['local'] },
{
id: 'local',
name: 'local',
isUngrouped: true,
rootToken: buildSidebarRootConnectionToken('local'),
connectionIds: ['local'],
},
{
id: 'prod',
name: '生产环境',
isUngrouped: undefined,
rootToken: buildSidebarRootTagToken('prod'),
connectionIds: ['dev241', 'dev240'],
},
]);
expect(getV2RailConnectionGroupBadgeText('Production')).toBe('PR');
expect(getV2RailConnectionGroupBadgeText('生产环境')).toBe('生');
@@ -285,7 +358,7 @@ describe('Sidebar locate toolbar', () => {
});
it('renders the v2 sidebar rail, command search hint, filter tabs and log footer', () => {
const markup = renderToStaticMarkup(<Sidebar uiVersion="v2" sqlLogCount={2341} />);
const markup = renderToStaticMarkup(<Sidebar uiVersion="v2" sqlLogCount={2341} onCreateConnection={mocks.noop} />);
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(markup).toContain('gn-v2-sidebar-redesign');
@@ -315,16 +388,14 @@ describe('Sidebar locate toolbar', () => {
expect(markup).toContain('data-sidebar-batch-database-action="true"');
expect(markup).toContain('data-sidebar-open-external-sql-file-action="true"');
expect(markup).toContain('data-sidebar-locate-current-tab-action="true"');
expect(markup).toContain('data-gonavi-create-connection-action="true"');
expect(markup).toContain('aria-label="AI 助手"');
expect(markup).toContain('data-gonavi-ai-entry-action="true"');
expect(markup).toContain('aria-label="工具"');
expect(markup).toContain('data-gonavi-open-tools-action="true"');
expect(markup).toContain('aria-label="设置"');
expect(source).toContain('buildV2RailConnectionGroups(connections, connectionTags)');
expect(source).toContain('data-v2-rail-connection-group="true"');
expect(source).toContain('data-v2-rail-connection-group-header="true"');
expect(source).toContain('buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder)');
expect(source).toContain("kind: 'v2-connection-group'");
expect(source).toContain('data-v2-rail-host-context-menu-trigger="true"');
expect(source).toContain('onContextMenu={(event) => openV2ConnectionContextMenu(event, conn)}');
expect(source).toContain("kind: 'v2-connection'");
expect(source).toContain("if (contextMenu.kind === 'v2-connection') return () => renderV2ConnectionContextMenu(contextMenu.node);");
@@ -365,6 +436,7 @@ describe('Sidebar locate toolbar', () => {
it('scales the v2 rail and footer tools from global appearance tokens', () => {
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
expect(css).toMatch(/\.gn-v2-rail-action-group,\s*body\[data-ui-version="v2"\] \.gn-v2-rail-system-actions \{[^}]*flex-direction: column;/s);
expect(css).toMatch(/\.gn-v2-rail-action-group,\s*body\[data-ui-version="v2"\] \.gn-v2-rail-system-actions \{[^}]*flex-direction: column;/s);
expect(css).toMatch(/\.gn-v2-rail-action-group \{[^}]*border-bottom: 0\.5px solid var\(--gn-br-1\);/s);
expect(css).toMatch(/\.gn-v2-explorer-toolbar\s*\{[^}]*display:\s*none\s*!important/s);
@@ -374,12 +446,11 @@ describe('Sidebar locate toolbar', () => {
expect(css).toMatch(/\.gn-v2-tree-title\.is-mono \.gn-v2-tree-label \{[^}]*font-size: clamp\(9px, calc\(var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\) - 1px\), 17px\);/s);
expect(css).toMatch(/\.gn-v2-tree-count \{[^}]*font-size: clamp\(9px, calc\(var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\) - 2px\), 16px\);/s);
expect(css).toMatch(/\.gn-v2-connection-rail \{[^}]*width: calc\(54px \* var\(--gn-ui-scale, 1\)\);[^}]*flex: 0 0 calc\(54px \* var\(--gn-ui-scale, 1\)\);/s);
expect(css).toMatch(/\.gn-v2-rail-items \{[^}]*padding-top: calc\(4px \* var\(--gn-ui-scale, 1\)\);/s);
expect(css).toMatch(/\.gn-v2-rail-group-header \{[^}]*overflow: visible;/s);
expect(css).toMatch(/\.gn-v2-rail-group-chevron \{[^}]*font-size: 10px;/s);
expect(css).toMatch(/\.gn-v2-rail-group-count \{[^}]*top: -1px;[^}]*right: -1px;[^}]*min-width: 16px;[^}]*height: 16px;[^}]*font-size: 9px;/s);
expect(css).toMatch(/\.gn-v2-rail-item,[^}]*\.gn-v2-rail-tool \{[^}]*width: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*height: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*font-size: var\(--gn-font-size-sm, 12px\);/s);
expect(css).toMatch(/\.gn-v2-rail-item,[^}]*\.gn-v2-rail-tool \{[^}]*width: calc\(64px \* var\(--gn-ui-scale, 1\)\);[^}]*height: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*font-size: var\(--gn-font-size-sm, 12px\);/s);
expect(css).toMatch(/\.gn-v2-rail-tool \{[^}]*height: calc\(32px \* var\(--gn-ui-scale, 1\)\);/s);
expect(css).toMatch(/\.gn-v2-rail-tool \{[^}]*width: calc\(34px \* var\(--gn-ui-scale, 1\)\);/s);
expect(css).toMatch(/\.gn-v2-active-connection-trigger \{[^}]*height: 34px;[^}]*border: 0;[^}]*background: transparent;/s);
expect(css).not.toContain('.gn-v2-active-connection-trigger:hover');
});
it('keeps v2 tree status dots circular while truncating only the label text', () => {
@@ -387,12 +458,16 @@ describe('Sidebar locate toolbar', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain('gn-v2-tree-status is-${status}');
expect(source).toContain('data-sidebar-tree-folder-icon="true"');
expect(css).toMatch(/\.gn-v2-tree-title\.is-connection \{[^}]*align-items:\s*center;/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title \{[^}]*overflow: visible;/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title > \.gn-v2-tree-title \{[^}]*overflow: visible;/s);
expect(css).toMatch(/\.gn-v2-tree-status \{[^}]*width: 14px;[^}]*height: 14px;[^}]*flex: 0 0 14px;[^}]*overflow: visible;/s);
expect(css).toMatch(/\.gn-v2-tree-status::before \{[^}]*width: 7px;[^}]*height: 7px;[^}]*border-radius: 50%;/s);
expect(css).toMatch(/\.gn-v2-tree-status\.is-success::before \{[^}]*background: #22c55e;[^}]*box-shadow: 0 0 0 4px rgba\(34, 197, 94, 0\.18\);/s);
expect(css).toMatch(/\.gn-v2-tree-label \{[^}]*overflow: hidden;[^}]*text-overflow: ellipsis;/s);
expect(css).toMatch(/\.gn-v2-tree-folder-icon \{[^}]*width: 22px;[^}]*height: 22px;[^}]*flex: 0 0 22px;/s);
expect(css).not.toContain('.gn-v2-tree-connection-meta');
});
it('does not repeat the active connection as an object-tree root in v2', () => {
@@ -405,7 +480,7 @@ describe('Sidebar locate toolbar', () => {
port: 3306,
},
}];
mocks.state.activeContext = { connectionId: 'conn-local', dbName: '' };
mocks.state.activeContext = { connectionId: 'conn-local', dbName: 'app_db' };
mocks.state.activeTabId = '';
mocks.state.tabs = [];
mocks.state.appearance = {
@@ -420,11 +495,39 @@ describe('Sidebar locate toolbar', () => {
expect(markup).toContain('gn-v2-connection-rail');
expect(markup).toContain('gn-v2-active-connection-copy');
expect(markup).toContain('<strong>本地</strong>');
expect(markup).toContain('<span>localhost</span>');
expect(markup).toContain('<span>app_db</span>');
expect(markup).not.toContain('<span>localhost</span>');
expect(markup).not.toContain('gn-v2-db-icon-label');
});
it('renders existing connection tags as collapsible groups in the v2 rail', () => {
it('shows an empty v2 active host header when no host is selected', () => {
mocks.state.connections = [{
id: 'conn-local',
name: '本地',
config: {
type: 'mysql',
host: 'localhost',
port: 3306,
},
}];
mocks.state.activeContext = null;
mocks.state.activeTabId = '';
mocks.state.tabs = [];
mocks.state.appearance = {
enabled: true,
opacity: 1,
blur: 0,
uiVersion: 'v2',
};
const markup = renderToStaticMarkup(<Sidebar uiVersion="v2" />);
expect(markup).toContain('<strong>未选择 Host</strong>');
expect(markup).toContain('<span>未选择数据库</span>');
expect(markup).not.toContain('<strong>本地</strong>');
});
it('keeps all filter backed by the full tree so hosts remain visible in v2', () => {
mocks.state.connections = [
{
id: 'dev240',
@@ -461,15 +564,168 @@ describe('Sidebar locate toolbar', () => {
};
const markup = renderToStaticMarkup(<Sidebar uiVersion="v2" />);
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(markup).toContain('data-v2-rail-connection-group="true"');
expect(markup).toContain('data-v2-rail-connection-group-header="true"');
expect(markup).toContain('title="生产环境 · 1 个连接"');
expect(markup).toContain('title="未分组 · 1 个连接"');
expect(markup).toContain('aria-label="折叠连接分组 生产环境"');
expect(markup).toContain('aria-label="切换到连接 dev240"');
expect(markup).toContain('aria-label="切换到连接 本地"');
expect(markup).toContain('data-v2-rail-host-context-menu-trigger="true"');
expect(source).toContain("if (v2ExplorerFilter === 'all') {");
expect(source).toContain('gn-v2-tree-connection-copy');
expect(source).not.toContain('gn-v2-tree-connection-meta');
});
it('reorders dragged connections instead of only moving them between groups', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain('const reorderConnections = useStore(state => state.reorderConnections);');
expect(source).toContain('reorderConnections(');
expect(source).toContain('const insertBefore = resolveSidebarDropInsertBefore(');
expect(source).toContain('const domDropNode = resolveSidebarDropNodeFromDomEvent(info?.event);');
expect(source).toContain('const dropTargetMetrics = resolveSidebarDropTargetMetricsFromDomEvent(info?.event);');
expect(source).toContain("findTreeNodeByKeyRef.current(treeDataRef.current, domDropNode.key)");
expect(source).toContain("const treeNode = baseElement.closest('.ant-tree-treenode') as HTMLElement | null;");
expect(source).toContain('insertBefore,');
});
it('reorders dragged tags relative to grouped connections instead of always appending them', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain("connectionTags.find(t => t.connectionIds.includes(String(dropNode.key)))?.id || ''");
expect(source).toContain('const dropTagId = dropNode.type === \'tag\'');
expect(source).toContain('if (dropTagId) {');
});
it('wires v2 rail root dragging through the shared sidebar root order action', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain('const reorderSidebarRoot = useStore(state => state.reorderSidebarRoot);');
expect(source).toContain('const [draggingV2RailRootToken, setDraggingV2RailRootToken] = useState(\'\');');
expect(source).toContain('const treeDragSelectSuppressUntilRef = useRef(0);');
expect(source).toContain('const treeDragSelectionSnapshotRef = useRef<');
expect(source).toContain('snapshotTreeSelectionBeforeDrag();');
expect(source).toContain('restoreTreeSelectionAfterDrag();');
expect(source).toContain('if (Date.now() < treeDragSelectSuppressUntilRef.current) {');
expect(source).toContain('handleV2RailRootDrop(');
expect(source).toContain('draggable');
expect(source).toContain('setDraggingV2RailRootToken(rootToken);');
expect(source).toContain('reorderSidebarRoot(sourceToken, targetToken, insertBefore);');
});
it('normalizes rc-tree absolute drop positions back to relative positions', () => {
expect(normalizeSidebarTreeRelativeDropPosition(4, '0-0-4')).toBe(0);
expect(normalizeSidebarTreeRelativeDropPosition(3, '0-0-4')).toBe(-1);
expect(normalizeSidebarTreeRelativeDropPosition(5, '0-0-4')).toBe(1);
});
it('resolves insert-before from either relative drop position or pointer position', () => {
expect(resolveSidebarDropInsertBefore(-1, null)).toBe(true);
expect(resolveSidebarDropInsertBefore(1, null)).toBe(false);
expect(resolveSidebarDropInsertBefore(0, {
clientY: 102,
top: 100,
height: 20,
})).toBe(true);
expect(resolveSidebarDropInsertBefore(0, {
clientY: 118,
top: 100,
height: 20,
})).toBe(false);
});
it('resolves sidebar drop node metadata from DOM markers', () => {
vi.stubGlobal('document', {
elementFromPoint: () => null,
});
const marker = {
getAttribute: (name: string) => {
if (name === 'data-sidebar-node-key') return 'conn-a';
if (name === 'data-sidebar-node-type') return 'connection';
return null;
},
};
const target = {
closest: (selector: string) => selector === '[data-sidebar-node-key]' ? marker : null,
};
expect(resolveSidebarDropNodeFromDomEvent({
target: target as unknown as EventTarget,
})).toEqual({
key: 'conn-a',
type: 'connection',
});
vi.unstubAllGlobals();
});
it('resolves sidebar drop target metrics from the full tree row instead of nested children', () => {
vi.stubGlobal('document', {
elementFromPoint: () => null,
});
const treeNode = {
getBoundingClientRect: () => ({
top: 128,
height: 26,
}),
};
const target = {
closest: (selector: string) => {
if (selector === '.ant-tree-treenode') return treeNode;
return null;
},
};
expect(resolveSidebarDropTargetMetricsFromDomEvent({
target: target as unknown as EventTarget,
})).toEqual({
top: 128,
height: 26,
});
vi.unstubAllGlobals();
});
it('treats centered tag drops as directional reordering instead of no-op', () => {
expect(resolveSidebarTagDropInsertBefore({
currentTagOrder: ['tag-dev', 'tag-test', 'tag-prod'],
dragTagId: 'tag-prod',
dropTagId: 'tag-dev',
relativeDropPosition: 0,
fallbackInsertBefore: false,
metrics: {
clientY: 113,
top: 100,
height: 26,
},
})).toBe(true);
expect(resolveSidebarTagDropInsertBefore({
currentTagOrder: ['tag-dev', 'tag-test', 'tag-prod'],
dragTagId: 'tag-dev',
dropTagId: 'tag-prod',
relativeDropPosition: 0,
fallbackInsertBefore: true,
metrics: {
clientY: 113,
top: 100,
height: 26,
},
})).toBe(false);
});
it('skips sidebar select side effects while tree dragging is active', () => {
expect(shouldSkipSidebarSelectWhileDragging(true, { selected: true })).toBe(true);
expect(shouldSkipSidebarSelectWhileDragging(false, { selected: false })).toBe(true);
expect(shouldSkipSidebarSelectWhileDragging(false, { selected: true })).toBe(false);
});
it('skips sidebar lazy load on expand while tree dragging is active', () => {
expect(shouldSkipSidebarLoadOnExpandWhileDragging(true, {
expanded: true,
node: { type: 'connection', children: undefined, isLeaf: false } as any,
})).toBe(true);
expect(shouldSkipSidebarLoadOnExpandWhileDragging(false, {
expanded: false,
node: { type: 'connection', children: undefined, isLeaf: false } as any,
})).toBe(true);
expect(shouldSkipSidebarLoadOnExpandWhileDragging(false, {
expanded: true,
node: { type: 'connection', children: undefined, isLeaf: false } as any,
})).toBe(false);
});
it('renders the v2 connection group context menu for rail group management', () => {

File diff suppressed because it is too large Load Diff

View File

@@ -497,6 +497,147 @@ describe('store appearance persistence', () => {
);
});
it('reorders connections inside tags and ungrouped roots independently', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'conn-a',
name: 'A',
config: { id: 'conn-a', type: 'mysql', host: 'a.local', port: 3306, user: 'root' },
},
{
id: 'conn-b',
name: 'B',
config: { id: 'conn-b', type: 'mysql', host: 'b.local', port: 3306, user: 'root' },
},
{
id: 'conn-c',
name: 'C',
config: { id: 'conn-c', type: 'mysql', host: 'c.local', port: 3306, user: 'root' },
},
{
id: 'conn-d',
name: 'D',
config: { id: 'conn-d', type: 'mysql', host: 'd.local', port: 3306, user: 'root' },
},
]);
useStore.getState().addConnectionTag({
id: 'tag-dev',
name: '开发',
connectionIds: ['conn-b', 'conn-d'],
});
useStore.getState().reorderConnections('conn-d', 'conn-b', 'tag-dev', true);
expect(useStore.getState().connectionTags[0]?.connectionIds).toEqual(['conn-d', 'conn-b']);
useStore.getState().reorderConnections('conn-c', 'conn-a', null, true);
expect(useStore.getState().connections.map((conn) => conn.id)).toEqual([
'conn-c',
'conn-a',
'conn-b',
'conn-d',
]);
});
it('reorders sidebar root items across tags and ungrouped hosts', async () => {
const {
buildSidebarRootConnectionToken,
buildSidebarRootTagToken,
resolveSidebarRootOrderTokens,
useStore,
} = await importStore();
useStore.getState().replaceConnections([
{
id: 'conn-a',
name: 'A',
config: { id: 'conn-a', type: 'mysql', host: 'a.local', port: 3306, user: 'root' },
},
{
id: 'conn-b',
name: 'B',
config: { id: 'conn-b', type: 'mysql', host: 'b.local', port: 3306, user: 'root' },
},
{
id: 'conn-c',
name: 'C',
config: { id: 'conn-c', type: 'mysql', host: 'c.local', port: 3306, user: 'root' },
},
]);
useStore.getState().addConnectionTag({
id: 'tag-dev',
name: '开发',
connectionIds: ['conn-b'],
});
const initialOrder = resolveSidebarRootOrderTokens(
useStore.getState().sidebarRootOrder,
useStore.getState().connectionTags,
useStore.getState().connections,
);
expect(initialOrder).toEqual([
buildSidebarRootTagToken('tag-dev'),
buildSidebarRootConnectionToken('conn-a'),
buildSidebarRootConnectionToken('conn-c'),
]);
useStore.getState().reorderSidebarRoot(
buildSidebarRootTagToken('tag-dev'),
buildSidebarRootConnectionToken('conn-c'),
false,
);
expect(resolveSidebarRootOrderTokens(
useStore.getState().sidebarRootOrder,
useStore.getState().connectionTags,
useStore.getState().connections,
)).toEqual([
buildSidebarRootConnectionToken('conn-a'),
buildSidebarRootConnectionToken('conn-c'),
buildSidebarRootTagToken('tag-dev'),
]);
});
it('restores ungrouped host root order after moving a host out of a tag', async () => {
const {
buildSidebarRootConnectionToken,
buildSidebarRootTagToken,
resolveSidebarRootOrderTokens,
useStore,
} = await importStore();
useStore.getState().replaceConnections([
{
id: 'conn-a',
name: 'A',
config: { id: 'conn-a', type: 'mysql', host: 'a.local', port: 3306, user: 'root' },
},
{
id: 'conn-b',
name: 'B',
config: { id: 'conn-b', type: 'mysql', host: 'b.local', port: 3306, user: 'root' },
},
]);
useStore.getState().addConnectionTag({
id: 'tag-dev',
name: '开发',
connectionIds: ['conn-b'],
});
useStore.getState().moveConnectionToTag('conn-a', 'tag-dev');
useStore.getState().moveConnectionToTag('conn-a', null);
expect(resolveSidebarRootOrderTokens(
useStore.getState().sidebarRootOrder,
useStore.getState().connectionTags,
useStore.getState().connections,
)).toEqual([
buildSidebarRootTagToken('tag-dev'),
buildSidebarRootConnectionToken('conn-a'),
]);
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

View File

@@ -851,6 +851,169 @@ const sanitizeConnectionTags = (value: unknown): ConnectionTag[] => {
return result;
};
const SIDEBAR_ROOT_TAG_TOKEN_PREFIX = "tag:";
const SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX = "connection:";
export const buildSidebarRootTagToken = (tagId: string): string =>
`${SIDEBAR_ROOT_TAG_TOKEN_PREFIX}${toTrimmedString(tagId)}`;
export const buildSidebarRootConnectionToken = (
connectionId: string,
): string => `${SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX}${toTrimmedString(connectionId)}`;
const isSidebarRootTagToken = (token: string): boolean =>
token.startsWith(SIDEBAR_ROOT_TAG_TOKEN_PREFIX) &&
token.length > SIDEBAR_ROOT_TAG_TOKEN_PREFIX.length;
const isSidebarRootConnectionToken = (token: string): boolean =>
token.startsWith(SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX) &&
token.length > SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX.length;
const sanitizeSidebarRootOrder = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const result: string[] = [];
value.forEach((entry) => {
const token = toTrimmedString(entry);
if (!token) return;
if (!isSidebarRootTagToken(token) && !isSidebarRootConnectionToken(token)) {
return;
}
if (seen.has(token)) return;
seen.add(token);
result.push(token);
});
return result;
};
const buildDefaultSidebarRootOrderTokens = (
connectionTags: ConnectionTag[],
connections: SavedConnection[],
): string[] => {
const groupedConnectionIds = new Set<string>();
connectionTags.forEach((tag) => {
tag.connectionIds.forEach((connectionId) => {
if (connectionId) groupedConnectionIds.add(connectionId);
});
});
return [
...connectionTags.map((tag) => buildSidebarRootTagToken(tag.id)),
...connections
.filter((connection) => !groupedConnectionIds.has(connection.id))
.map((connection) => buildSidebarRootConnectionToken(connection.id)),
];
};
export const resolveSidebarRootOrderTokens = (
sidebarRootOrder: unknown,
connectionTags: ConnectionTag[],
connections: SavedConnection[],
): string[] => {
const defaultOrder = buildDefaultSidebarRootOrderTokens(
connectionTags,
connections,
);
if (defaultOrder.length === 0) {
return [];
}
const validTokens = new Set(defaultOrder);
const seen = new Set<string>();
const result: string[] = [];
sanitizeSidebarRootOrder(sidebarRootOrder).forEach((token) => {
if (!validTokens.has(token) || seen.has(token)) return;
seen.add(token);
result.push(token);
});
defaultOrder.forEach((token) => {
if (seen.has(token)) return;
seen.add(token);
result.push(token);
});
return result;
};
const insertSidebarRootTokenBeforeUngrouped = (
sidebarRootOrder: string[],
token: string,
): string[] => {
if (!token || sidebarRootOrder.includes(token)) {
return [...sidebarRootOrder];
}
const firstConnectionIndex = sidebarRootOrder.findIndex(
isSidebarRootConnectionToken,
);
if (firstConnectionIndex === -1) {
return [...sidebarRootOrder, token];
}
const nextOrder = [...sidebarRootOrder];
nextOrder.splice(firstConnectionIndex, 0, token);
return nextOrder;
};
const insertSidebarRootTokenAfter = (
sidebarRootOrder: string[],
token: string,
anchorToken: string,
): string[] => {
if (!token) return [...sidebarRootOrder];
const nextOrder = sidebarRootOrder.filter((item) => item !== token);
const anchorIndex = nextOrder.indexOf(anchorToken);
if (anchorIndex === -1) {
nextOrder.push(token);
return nextOrder;
}
nextOrder.splice(anchorIndex + 1, 0, token);
return nextOrder;
};
const moveSidebarRootToken = (
sidebarRootOrder: string[],
sourceToken: string,
targetToken: string,
insertBefore: boolean,
): string[] => {
if (!sourceToken || !targetToken || sourceToken === targetToken) {
return [...sidebarRootOrder];
}
const filtered = sidebarRootOrder.filter((token) => token !== sourceToken);
const targetIndex = filtered.indexOf(targetToken);
const insertIndex =
targetIndex === -1
? filtered.length
: Math.max(
0,
Math.min(
filtered.length,
insertBefore ? targetIndex : targetIndex + 1,
),
);
filtered.splice(insertIndex, 0, sourceToken);
return filtered;
};
const orderConnectionTagsBySidebarRootOrder = (
connectionTags: ConnectionTag[],
sidebarRootOrder: string[],
): ConnectionTag[] => {
const tagMap = new Map(connectionTags.map((tag) => [tag.id, tag]));
const orderedTags: ConnectionTag[] = [];
sidebarRootOrder.forEach((token) => {
if (!isSidebarRootTagToken(token)) return;
const tagId = token.slice(SIDEBAR_ROOT_TAG_TOKEN_PREFIX.length);
const tag = tagMap.get(tagId);
if (!tag) return;
orderedTags.push(tag);
tagMap.delete(tagId);
});
orderedTags.push(...Array.from(tagMap.values()));
return orderedTags;
};
const isLegacyDefaultAppearance = (
appearance: Partial<{ opacity: number; blur: number }> | undefined,
): boolean => {
@@ -887,6 +1050,7 @@ export interface QueryOptions {
interface AppState {
connections: SavedConnection[];
connectionTags: ConnectionTag[];
sidebarRootOrder: string[];
tabs: TabData[];
activeTabId: string | null;
activeContext: { connectionId: string; dbName: string } | null;
@@ -955,7 +1119,18 @@ interface AppState {
connectionId: string,
targetTagId: string | null,
) => void;
reorderConnections: (
connectionId: string,
targetConnectionId: string,
targetTagId: string | null,
insertBefore?: boolean,
) => void;
reorderTags: (tagIds: string[]) => void;
reorderSidebarRoot: (
sourceToken: string,
targetToken: string,
insertBefore: boolean,
) => void;
addTab: (tab: TabData) => void;
updateQueryTabDraft: (
@@ -1672,6 +1847,7 @@ export const useStore = create<AppState>()(
(set) => ({
connections: [],
connectionTags: [],
sidebarRootOrder: [],
tabs: [],
activeTabId: null,
activeContext: null,
@@ -1739,31 +1915,87 @@ export const useStore = create<AppState>()(
};
}),
removeConnection: (id) =>
set((state) => ({
connections: state.connections.filter((c) => c.id !== id),
connectionTags: state.connectionTags.map((tag) => ({
set((state) => {
const nextConnections = state.connections.filter((c) => c.id !== id);
const nextTags = state.connectionTags.map((tag) => ({
...tag,
connectionIds: tag.connectionIds.filter((cid) => cid !== id),
})),
})),
}));
return {
connections: nextConnections,
connectionTags: nextTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder.filter(
(token) => token !== buildSidebarRootConnectionToken(id),
),
nextTags,
nextConnections,
),
};
}),
replaceConnections: (connections) =>
set((state) => ({
connections: sanitizeConnections(connections),
shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions,
})),
set((state) => {
const nextConnections = sanitizeConnections(connections);
return {
connections: nextConnections,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
state.connectionTags,
nextConnections,
),
shortcutOptions:
readPersistedShortcutOptions() ?? state.shortcutOptions,
};
}),
addConnectionTag: (tag) =>
set((state) => ({ connectionTags: [...state.connectionTags, tag] })),
set((state) => {
const nextTags = [...state.connectionTags, tag];
const nextRootOrder = insertSidebarRootTokenBeforeUngrouped(
resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
state.connectionTags,
state.connections,
),
buildSidebarRootTagToken(tag.id),
);
return {
connectionTags: nextTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
nextRootOrder,
nextTags,
state.connections,
),
};
}),
updateConnectionTag: (tag) =>
set((state) => ({
connectionTags: state.connectionTags.map((t) =>
set((state) => {
const nextTags = state.connectionTags.map((t) =>
t.id === tag.id ? tag : t,
),
})),
);
return {
connectionTags: nextTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
nextTags,
state.connections,
),
};
}),
removeConnectionTag: (id) =>
set((state) => ({
connectionTags: state.connectionTags.filter((t) => t.id !== id),
})),
set((state) => {
const nextTags = state.connectionTags.filter((t) => t.id !== id);
return {
connectionTags: nextTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder.filter(
(token) => token !== buildSidebarRootTagToken(id),
),
nextTags,
state.connections,
),
};
}),
moveConnectionToTag: (connectionId, targetTagId) =>
set((state) => {
const newTags = state.connectionTags.map((tag) => {
@@ -1776,22 +2008,186 @@ export const useStore = create<AppState>()(
}
return { ...tag, connectionIds: filteredIds };
});
return { connectionTags: newTags };
const nextRootOrder = resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
newTags,
state.connections,
);
const connectionToken = buildSidebarRootConnectionToken(connectionId);
if (targetTagId) {
return {
connectionTags: newTags,
sidebarRootOrder: nextRootOrder.filter(
(token) => token !== connectionToken,
),
};
}
const sourceToken = buildSidebarRootTagToken(
state.connectionTags.find((tag) =>
tag.connectionIds.includes(connectionId),
)?.id || "",
);
const insertedRootOrder = sourceToken
? insertSidebarRootTokenAfter(nextRootOrder, connectionToken, sourceToken)
: insertSidebarRootTokenBeforeUngrouped(nextRootOrder, connectionToken);
return {
connectionTags: newTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
insertedRootOrder,
newTags,
state.connections,
),
};
}),
reorderConnections: (
connectionId,
targetConnectionId,
targetTagId,
insertBefore = false,
) =>
set((state) => {
if (
!connectionId ||
!targetConnectionId ||
connectionId === targetConnectionId
) {
return {
connections: state.connections,
connectionTags: state.connectionTags,
};
}
const normalizeInsertIndex = (
length: number,
index: number,
): number => Math.max(0, Math.min(length, index));
const nextTags = state.connectionTags.map((tag) => ({
...tag,
connectionIds: tag.connectionIds.filter((id) => id !== connectionId),
}));
if (targetTagId) {
const updatedTags = nextTags.map((tag) => {
if (tag.id !== targetTagId) {
return tag;
}
const targetIndex = tag.connectionIds.indexOf(targetConnectionId);
if (targetIndex === -1) {
return {
...tag,
connectionIds: [...tag.connectionIds, connectionId],
};
}
const insertIndex = normalizeInsertIndex(
tag.connectionIds.length,
insertBefore ? targetIndex : targetIndex + 1,
);
const nextIds = [...tag.connectionIds];
nextIds.splice(insertIndex, 0, connectionId);
return { ...tag, connectionIds: nextIds };
});
return {
connections: state.connections,
connectionTags: updatedTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
updatedTags,
state.connections,
),
};
}
const ungroupedIds = state.connections
.map((conn) => conn.id)
.filter((id) => id !== connectionId)
.filter((id) => !nextTags.some((tag) => tag.connectionIds.includes(id)));
const targetIndex = ungroupedIds.indexOf(targetConnectionId);
const insertIndex =
targetIndex === -1
? ungroupedIds.length
: normalizeInsertIndex(
ungroupedIds.length,
insertBefore ? targetIndex : targetIndex + 1,
);
const nextUngroupedIds = [...ungroupedIds];
nextUngroupedIds.splice(insertIndex, 0, connectionId);
const ungroupedOrderMap = new Map(
nextUngroupedIds.map((id, index) => [id, index]),
);
const nextConnections = [...state.connections].sort((a, b) => {
const indexA = ungroupedOrderMap.get(a.id);
const indexB = ungroupedOrderMap.get(b.id);
if (typeof indexA === 'number' && typeof indexB === 'number') {
return indexA - indexB;
}
if (typeof indexA === 'number') {
return -1;
}
if (typeof indexB === 'number') {
return 1;
}
return 0;
});
return {
connections: nextConnections,
connectionTags: nextTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
nextTags,
nextConnections,
),
};
}),
reorderTags: (tagIds) =>
set((state) => {
const tagMap = new Map(state.connectionTags.map((t) => [t.id, t]));
const newTags: ConnectionTag[] = [];
tagIds.forEach((id) => {
const tag = tagMap.get(id);
if (tag) {
newTags.push(tag);
tagMap.delete(id);
}
});
// 追加未指定的tag如果有的话
newTags.push(...Array.from(tagMap.values()));
return { connectionTags: newTags };
const nextRootOrder = resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
state.connectionTags,
state.connections,
);
const orderedRootOrder = [
...tagIds.map((id) => buildSidebarRootTagToken(id)),
...nextRootOrder.filter((token) => !isSidebarRootTagToken(token)),
];
const newTags = orderConnectionTagsBySidebarRootOrder(
state.connectionTags,
orderedRootOrder,
);
return {
connectionTags: newTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
orderedRootOrder,
newTags,
state.connections,
),
};
}),
reorderSidebarRoot: (sourceToken, targetToken, insertBefore) =>
set((state) => {
const nextRootOrder = moveSidebarRootToken(
resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
state.connectionTags,
state.connections,
),
sourceToken,
targetToken,
insertBefore,
);
return {
sidebarRootOrder: resolveSidebarRootOrderTokens(
nextRootOrder,
state.connectionTags,
state.connections,
),
connectionTags: orderConnectionTagsBySidebarRootOrder(
state.connectionTags,
nextRootOrder,
),
};
}),
addTab: (tab) =>
@@ -2504,6 +2900,11 @@ export const useStore = create<AppState>()(
state.connectionTags,
);
}
nextState.sidebarRootOrder = resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
nextState.connectionTags,
nextState.connections,
);
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
nextState.externalSQLDirectories = sanitizeExternalSQLDirectories(
state.externalSQLDirectories,
@@ -2575,6 +2976,11 @@ export const useStore = create<AppState>()(
...state,
connections: sanitizeConnections(state.connections),
connectionTags: sanitizeConnectionTags(state.connectionTags),
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
sanitizeConnectionTags(state.connectionTags),
sanitizeConnections(state.connections),
),
tabs: safeTabs,
activeTabId: sanitizeActiveTabId(state.activeTabId, safeTabs),
savedQueries: sanitizeSavedQueries(state.savedQueries),
@@ -2621,6 +3027,7 @@ export const useStore = create<AppState>()(
tabs,
activeTabId: sanitizeActiveTabId(state.activeTabId, tabs),
connectionTags: state.connectionTags,
sidebarRootOrder: state.sidebarRootOrder,
savedQueries: state.savedQueries,
externalSQLDirectories: state.externalSQLDirectories,
theme: state.theme,