mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-31 09:29:47 +08:00
🐛 fix(sidebar): 修复新版左侧分组与 Host 拖拽排序
- 新增 sidebarRootOrder 持久化左侧根节点顺序 - 支持分组与未分组 Host 在新版左侧根层混排 - 统一 v2 rail 与树视图拖拽写回根层排序 - 拖拽期间抑制误选中与 Host 误切换 - 补充 Sidebar 与 store 拖拽排序回归测试
This commit is contained in:
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user