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:
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 () => {
|
||||
|
||||
@@ -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, [
|
||||
|
||||
Reference in new issue
Block a user