feat(editor): add table copy/paste keyboard shortcuts

* feat: add table copy/paste with configurable structure and data options

- Implement Navicat-style table copy/paste functionality
- Support multi-table selection and batch operations
- Configurable paste modes: structure+data (default), structure only, data only
- Backend: add build_copy_table_data_sql API (INSERT INTO ... SELECT)
- TreeItem.vue: copy/paste context menus, paste dialog with mode selection
- ObjectBrowser.vue: batch toolbar buttons, context menu, paste dialog
- Refactor TreeClipboard type to support multi-table entries
- Add i18n keys for all 7 locale files (en, zh-CN, zh-TW, ja, es, it, pt-BR)

* Fix table paste shortcuts

* Fix Kafka MQ sidebar detection

* Restrict table paste scope

* Support data copy for primary SQL databases
This commit is contained in:
miracle authored and GitHub committed 2026-07-01 12:49:37 +08:00
1 parent 5d2ce9f189
commit 0c91fecf5d
26 files changed
+943 -34

No files matched your search

@@ -33,6 +33,28 @@ function mqConnection(): ConnectionConfig {
} as ConnectionConfig;
}
function kafkaConnection(): ConnectionConfig {
return {
...mqConnection(),
name: "Apache Kafka",
driver_profile: "kafka",
driver_label: "Apache Kafka",
external_config: {
systemKind: "kafka",
adminUrl: "",
auth: { kind: "none" },
extra: { bootstrapServers: "127.0.0.1:9092" },
},
} as ConnectionConfig;
}
function kafkaExternalConfigOnlyConnection(): ConnectionConfig {
const connection = kafkaConnection();
delete connection.driver_profile;
delete connection.driver_label;
return connection;
}
describe("connectionStore MQ sidebar tree", () => {
beforeEach(() => {
vi.resetModules();
@@ -83,6 +105,72 @@ describe("connectionStore MQ sidebar tree", () => {
expect(node.isExpanded).toBe(true);
});
it("adds a Kafka topics child with a topics initial tab", async () => {
vi.doMock("@/lib/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listDatabases: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
mqListTenants: vi.fn(),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = kafkaConnection();
const node: TreeNode = {
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: false,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [node];
await store.refreshTreeNode(node);
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_kafka", initialTab: "topics" }]);
});
it("detects Kafka from external config when driver profile is missing", async () => {
const mqListTenants = vi.fn();
vi.doMock("@/lib/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listDatabases: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
mqListTenants,
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = kafkaExternalConfigOnlyConnection();
const node: TreeNode = {
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: false,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [node];
await store.refreshTreeNode(node);
expect(mqListTenants).not.toHaveBeenCalled();
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_kafka", initialTab: "topics" }]);
});
it("reuses an in-flight connection attempt instead of recording stale superseded errors", async () => {
let resolveConnect: ((value: string) => void) | undefined;
const connectDb = vi.fn(
@@ -124,10 +212,12 @@ describe("connectionStore MQ sidebar tree", () => {
const firstTabId = queryStore.openMqAdmin("mq-1", { tenant: "public" });
expect(queryStore.tabs.find((tab) => tab.id === firstTabId)?.mqTenant).toBe("public");
expect(queryStore.tabs.find((tab) => tab.id === firstTabId)?.mqInitialTab).toBeUndefined();
const secondTabId = queryStore.openMqAdmin("mq-1", { tenant: "tenant-a" });
const secondTabId = queryStore.openMqAdmin("mq-1", { tenant: "tenant-a", initialTab: "topics" });
expect(secondTabId).toBe(firstTabId);
expect(queryStore.tabs.find((tab) => tab.id === firstTabId)?.mqTenant).toBe("tenant-a");
expect(queryStore.tabs.find((tab) => tab.id === firstTabId)?.mqInitialTab).toBe("topics");
});
it("preserves the selected tenant when duplicating an MQ admin tab", async () => {
+16 -6
View File
@@ -58,6 +58,7 @@ import { kvRootNodeLabel } from "@/lib/kvRootPresentation";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redisKeyPattern";
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint";
import i18n from "@/i18n";
import type { MqAdminConfig } from "@/types/mq";
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection";
@@ -75,6 +76,13 @@ function sidebarObjectGroupPageSize(): number {
const size = settingsStore.desktopSettings.sidebar_table_page_size;
return typeof size === "number" && size > 0 ? size : 500;
}
function isKafkaMqConnection(config: ConnectionConfig | undefined): boolean {
if (!config || config.db_type !== "mq") return false;
if (config.driver_profile === "kafka") return true;
return (config.external_config as Partial<MqAdminConfig> | undefined)?.systemKind === "kafka";
}
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
interface LocateTableTarget {
@@ -127,14 +135,18 @@ function ensureSqlServerLinkedRootNode(connectionId: string, children: TreeNode[
// Temporary storage for DataGrip import payload (used to read Keychain passwords after import)
let pendingDataGripPayload: { format: "datagrip-import"; dataSources: string; dataSourcesLocal?: string } | null = null;
interface TreeClipboardTableStructure {
kind: "table-structure";
interface TreeClipboardTableEntry {
connectionId: string;
database: string;
schema?: string;
tableName: string;
}
export interface TreeClipboard {
kind: "table-copy";
tables: TreeClipboardTableEntry[];
}
interface LoadTreeOptions {
force?: boolean;
expectedSidebarSearchQuery?: string;
@@ -160,7 +172,7 @@ export const useConnectionStore = defineStore("connection", () => {
const selectedTreeNodeId = ref<string | null>(null);
const selectedTreeNodeIds = ref<string[]>([]);
const treeSelectionAnchorId = ref<string | null>(null);
const treeClipboard = ref<TreeClipboardTableStructure | null>(null);
const treeClipboard = ref<TreeClipboard | null>(null);
watch(activeConnectionId, (id) => {
if (id) localStorage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, id);
@@ -1627,9 +1639,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (useCachedChildren(node, options)) return;
const config = getConfig(connectionId);
const isKafka = config?.driver_profile === "kafka";
if (isKafka) {
if (isKafkaMqConnection(config)) {
// Kafka has no tenant/namespace concept. Create a synthetic child
// that opens the MQ admin console directly when clicked.
setChildren(node, [