test(editor): cover context menu routing payloads

This commit is contained in:
LRcoding authored and GitHub committed 2026-07-15 20:53:22 +08:00
1 parent d3118256a4
commit 711d0ec57b
13 files changed
+484 -197

No files matched your search

@@ -1,6 +1,7 @@
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
import { isSqlKeyword } from "@/lib/sql/sqlNavigation";
import { extractIdentifierPartsAt, isSqlKeyword, sqlObjectNavigationTarget, type SqlObjectNavigationTarget, type SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
import type { ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import type { DatabaseType, QueryTab, TreeNode } from "@/types/database";
export interface QueryCursorTableCandidate {
@@ -10,99 +11,28 @@ export interface QueryCursorTableCandidate {
tableName: string;
}
export interface IdentifierPart {
value: string;
quoted: boolean;
export interface QueryTableCandidateAtPositionInput {
connectionId: string;
database: string;
schema?: string;
databaseType?: DatabaseType;
sql: string;
position: number;
}
function identifierChar(ch: string | undefined): boolean {
return !!ch && (/[A-Za-z0-9_$."`-]/.test(ch) || ch === "[" || ch === "]");
}
export type QueryContextObjectAction = "view-data" | "edit-table-structure" | "edit-view" | "view-source" | "view-ddl";
function readIdentifierWindow(sql: string, pos: number): string {
const clamped = Math.max(0, Math.min(pos, sql.length));
let from = clamped;
let to = clamped;
export type QueryContextObjectRoute =
| { event: "viewTableData"; payload: [target: SqlObjectNavigationTarget] }
| { event: "editTableStructure"; payload: [target: SqlObjectNavigationTarget] }
| { event: "openObjectSource"; payload: [target: SqlObjectNavigationTarget, initialEditing: boolean] }
| { event: "viewTableDdl"; payload: [target: SqlObjectNavigationTarget] };
if (!identifierChar(sql[from]) && identifierChar(sql[from - 1])) {
from -= 1;
to = from + 1;
}
if (!identifierChar(sql[from])) return "";
while (from > 0 && identifierChar(sql[from - 1])) from -= 1;
while (to < sql.length && identifierChar(sql[to])) to += 1;
return sql.slice(from, to).replace(/^\.+|\.+$/g, "");
}
function splitQualifiedIdentifier(text: string): IdentifierPart[] {
const parts: IdentifierPart[] = [];
let current = "";
let quoted = false;
let quote: "`" | '"' | "]" | null = null;
function pushPart() {
const value = current.trim();
if (value) parts.push({ value, quoted });
current = "";
quoted = false;
}
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (quote) {
if (quote === "]" && ch === "]") {
if (text[i + 1] === "]") {
current += "]";
i += 1;
} else {
quote = null;
}
continue;
}
if ((quote === "`" || quote === '"') && ch === quote) {
if (text[i + 1] === quote) {
current += ch;
i += 1;
} else {
quote = null;
}
continue;
}
current += ch;
continue;
}
if (ch === ".") {
pushPart();
continue;
}
if (ch === "`" || ch === '"') {
quoted = true;
quote = ch;
continue;
}
if (ch === "[") {
quoted = true;
quote = "]";
continue;
}
current += ch;
}
pushPart();
return parts;
}
export function extractQualifiedIdentifierPartsAt(sql: string, pos: number): IdentifierPart[] {
const text = readIdentifierWindow(sql, pos);
if (!text) return [];
const parts = splitQualifiedIdentifier(text);
export function extractQualifiedIdentifierPartsAt(sql: string, pos: number) {
let parts = extractIdentifierPartsAt(sql, pos);
// CodeMirror can report the boundary immediately after the clicked token;
// retain the legacy behavior that treats that boundary as part of the identifier.
if (parts.length === 0 && pos > 0) parts = extractIdentifierPartsAt(sql, pos - 1);
const last = parts[parts.length - 1];
if (!last || (!last.quoted && isSqlKeyword(last.value))) return [];
return parts;
@@ -112,18 +42,29 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
if (!tab || tab.mode !== "query" || !tab.connectionId || !tab.database) return null;
const cursor = tab.editorSelection?.head ?? tab.editorSelection?.anchor ?? tab.sql.length;
const parts = extractQualifiedIdentifierPartsAt(tab.sql, cursor).map((part) => part.value);
return queryTableCandidateAtSqlPosition({
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
databaseType,
sql: tab.sql,
position: cursor,
});
}
export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPositionInput): QueryCursorTableCandidate | null {
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => part.value);
if (parts.length === 0) return null;
const tableName = parts[parts.length - 1];
let database = tab.database;
let schema = tab.schema;
let database = input.database;
let schema = input.schema;
if (parts.length >= 3) {
database = parts[parts.length - 3];
schema = parts[parts.length - 2];
} else if (parts.length === 2) {
if (databaseType && !isSchemaAware(databaseType) && !isSingleDatabase(databaseType)) {
if (input.databaseType && !isSchemaAware(input.databaseType) && !isSingleDatabase(input.databaseType)) {
database = parts[0];
schema = undefined;
} else {
@@ -131,7 +72,46 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
}
}
return { connectionId: tab.connectionId, database, schema, tableName };
return { connectionId: input.connectionId, database, schema, tableName };
}
export function resolveQueryContextCandidateDatabase(candidate: QueryCursorTableCandidate, databases: readonly string[]): QueryCursorTableCandidate {
const database = databases.find((name) => sameIdentifier(name, candidate.database));
return database && database !== candidate.database ? { ...candidate, database } : candidate;
}
export function resolveQueryContextObjectTarget(candidate: QueryCursorTableCandidate, tables: readonly SqlCompletionTable[]): SqlObjectNavigationTarget {
const nameMatches = tables.filter((table) => sameIdentifier(table.name, candidate.tableName));
const match = candidate.schema ? (nameMatches.find((table) => sameIdentifier(table.schema, candidate.schema)) ?? nameMatches.find((table) => !table.schema)) : nameMatches[0];
return sqlObjectNavigationTarget({
name: match?.name ?? candidate.tableName,
database: candidate.database,
schema: match?.schema ?? candidate.schema,
type: match?.type,
});
}
export function queryContextObjectActions(type?: SqlObjectNavigationType): QueryContextObjectAction[] {
if (type === "view" || type === "materialized_view") {
return ["view-data", "edit-view", "view-source", "view-ddl"];
}
// Unknown metadata preserves the historical table actions instead of disabling existing entry points.
return ["view-data", "edit-table-structure", "view-ddl"];
}
export function queryContextObjectRoute(action: QueryContextObjectAction, target: SqlObjectNavigationTarget): QueryContextObjectRoute {
switch (action) {
case "view-data":
return { event: "viewTableData", payload: [target] };
case "edit-table-structure":
return { event: "editTableStructure", payload: [target] };
case "edit-view":
return { event: "openObjectSource", payload: [target, true] };
case "view-source":
return { event: "openObjectSource", payload: [target, false] };
case "view-ddl":
return { event: "viewTableDdl", payload: [target] };
}
}
export function qualifiedTableNameAtSqlPosition(sql: string, pos: number): string | null {