Merge remote-tracking branch 'upstream/main' into codex/fix-260-482-overflow-navigation
# Conflicts: # apps/desktop/src/components/layout/AppTabBar.vue # apps/desktop/src/lib/tabPresentation.ts
This commit is contained in:
commit
e36e42723a
52 files changed
+2915
-1044
No files matched your search
@@ -0,0 +1,118 @@
|
||||
import type { CellValue } from "@/lib/cellValue";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
||||
export type BinaryCellDownloadMode = "binary" | "utf8" | "gbk";
|
||||
|
||||
export interface BinaryCellDownloadPayload {
|
||||
data: Uint8Array | string;
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
export interface BinaryCellDownloadResult {
|
||||
kind: "saved" | "browser-download" | "cancelled";
|
||||
path?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const BINARY_CELL_DOWNLOAD_MODES: BinaryCellDownloadMode[] = ["binary", "utf8", "gbk"];
|
||||
|
||||
const HEX_VALUE_RE = /^(?:0[xX]|\\x)([0-9a-fA-F\s]+)$/;
|
||||
const BINARY_TYPE_RE =
|
||||
/^(?:blob|tinyblob|mediumblob|longblob|bytea|bytes|binary|varbinary|image|raw|long\s+raw)(?:\b|\()/i;
|
||||
|
||||
export function parseBinaryCellHexValue(value: CellValue): Uint8Array | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const match = value.trim().match(HEX_VALUE_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const hex = match[1].replace(/\s+/g, "");
|
||||
if (!hex || hex.length % 2 !== 0) return null;
|
||||
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const parsed = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
bytes[i] = parsed;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function isBinaryCellColumnType(columnType?: string): boolean {
|
||||
const type = (columnType ?? "").trim();
|
||||
return !!type && BINARY_TYPE_RE.test(type);
|
||||
}
|
||||
|
||||
export function canDownloadBinaryCellValue(value: CellValue, columnType?: string): boolean {
|
||||
if (!parseBinaryCellHexValue(value)) return false;
|
||||
return isBinaryCellColumnType(columnType) || typeof value === "string";
|
||||
}
|
||||
|
||||
export function binaryCellDownloadPayload(value: CellValue, mode: BinaryCellDownloadMode): BinaryCellDownloadPayload {
|
||||
const bytes = parseBinaryCellHexValue(value);
|
||||
if (!bytes) {
|
||||
throw new Error("Cell value is not a hexadecimal binary value.");
|
||||
}
|
||||
|
||||
if (mode === "binary") {
|
||||
return {
|
||||
data: bytes,
|
||||
mimeType: "application/octet-stream",
|
||||
extension: "bin",
|
||||
};
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder(mode === "gbk" ? "gbk" : "utf-8", { fatal: false });
|
||||
return {
|
||||
data: decoder.decode(bytes),
|
||||
mimeType: "text/plain;charset=utf-8",
|
||||
extension: "txt",
|
||||
};
|
||||
}
|
||||
|
||||
export function binaryCellDownloadFileName(options: {
|
||||
column: string;
|
||||
rowNumber: number;
|
||||
mode: BinaryCellDownloadMode;
|
||||
extension: string;
|
||||
}): string {
|
||||
const column = options.column
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, "_")
|
||||
.slice(0, 48);
|
||||
const safeColumn = column || "cell";
|
||||
const suffix = options.mode === "binary" ? "" : `-${options.mode}`;
|
||||
return `${safeColumn}-row-${options.rowNumber}${suffix}.${options.extension}`;
|
||||
}
|
||||
|
||||
export async function downloadBinaryCellPayload(
|
||||
payload: BinaryCellDownloadPayload,
|
||||
fileName: string,
|
||||
): Promise<BinaryCellDownloadResult> {
|
||||
if (isTauriRuntime()) {
|
||||
const [{ save }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const path = await save({
|
||||
defaultPath: fileName,
|
||||
filters: [{ name: payload.extension.toUpperCase(), extensions: [payload.extension] }],
|
||||
});
|
||||
if (!path) return { kind: "cancelled" };
|
||||
if (typeof payload.data === "string") {
|
||||
await fs.writeTextFile(path, payload.data);
|
||||
} else {
|
||||
await fs.writeFile(path, payload.data);
|
||||
}
|
||||
return { kind: "saved", path };
|
||||
}
|
||||
|
||||
const blob = new Blob([payload.data], { type: payload.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return { kind: "browser-download", fileName };
|
||||
}
|
||||
@@ -789,7 +789,7 @@ export async function loadAiConfig(): Promise<AiConfig | null> {
|
||||
}
|
||||
|
||||
export async function loadDesktopSettings(): Promise<DesktopSettings> {
|
||||
return { show_tray_icon: true };
|
||||
return { show_tray_icon: true, icon_theme: "default" };
|
||||
}
|
||||
|
||||
export async function saveDesktopSettings(_settings: DesktopSettings): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ObjectInfo } from "@/types/database";
|
||||
import { normalizeDatabaseObjectName } from "@/lib/tableTree";
|
||||
import { createDatabaseObjectNameComparator, normalizeDatabaseObjectName } from "@/lib/tableTree";
|
||||
|
||||
export type ObjectBrowserRow = {
|
||||
id: string;
|
||||
@@ -111,10 +111,14 @@ export function sortObjectBrowserRows(
|
||||
direction: ObjectBrowserSortDirection,
|
||||
): ObjectBrowserRow[] {
|
||||
const multiplier = direction === "asc" ? 1 : -1;
|
||||
const compareNames = createDatabaseObjectNameComparator(rows.map((row) => row.name));
|
||||
return [...rows].sort((left, right) => {
|
||||
const compared = compareObjectBrowserValue(left[key], right[key], key, direction);
|
||||
const compared =
|
||||
key === "name"
|
||||
? compareNames(left.name, right.name)
|
||||
: compareObjectBrowserValue(left[key], right[key], key, direction);
|
||||
if (compared !== 0) return compared * multiplier;
|
||||
return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" });
|
||||
return compareNames(left.name, right.name);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
export function connectionDisplayName(connectionId: string): string {
|
||||
const connectionStore = useConnectionStore();
|
||||
return connectionStore.getConfig(connectionId)?.name || connectionId;
|
||||
@@ -13,28 +14,21 @@ export function connectionColor(connectionId: string): string {
|
||||
return connectionStore.getConfig(connectionId)?.color || "";
|
||||
}
|
||||
|
||||
export function databaseDisplayNameForTab(connectionId: string, database: string): string {
|
||||
const { t } = useI18n();
|
||||
export function databaseDisplayNameForTab(connectionId: string, database: string, t: Translate): string {
|
||||
const connectionStore = useConnectionStore();
|
||||
const connection = connectionStore.getConfig(connectionId);
|
||||
if (connection?.db_type === "redis" && database !== "") return `db${database}`;
|
||||
return database || t("editor.noDatabase");
|
||||
}
|
||||
|
||||
export function tabGroupLabel(tab: Pick<QueryTab, "connectionId" | "database">): string {
|
||||
const connection = connectionDisplayName(tab.connectionId);
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
|
||||
return database ? `${connection} / ${database}` : connection;
|
||||
}
|
||||
|
||||
export function isPreviewTab(tab: QueryTab): boolean {
|
||||
const connectionStore = useConnectionStore();
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
return !!config?.name.startsWith("[Preview]");
|
||||
}
|
||||
|
||||
export function tabDisplayTitle(tab: QueryTab): string {
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
|
||||
export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
|
||||
const settingsStore = useSettingsStore();
|
||||
const compact = settingsStore.editorSettings.compactTabTitle;
|
||||
if (isPreviewTab(tab)) return tab.title;
|
||||
@@ -66,10 +60,9 @@ export function tabDisplayTitle(tab: QueryTab): string {
|
||||
return tab.title;
|
||||
}
|
||||
|
||||
export function tabTooltipLines(tab: QueryTab): { label: string; value: string }[] {
|
||||
const { t } = useI18n();
|
||||
export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; value: string }[] {
|
||||
const connName = connectionDisplayName(tab.connectionId);
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
|
||||
const lines: { label: string; value: string }[] = [
|
||||
{ label: t("tabs.tooltipConnection"), value: connName },
|
||||
{ label: t("tabs.tooltipDatabase"), value: database },
|
||||
@@ -95,8 +88,7 @@ export function shouldShowTabOverflowControls(
|
||||
return tabCount > 0 && (hasTabOverflow || canScrollLeft || canScrollRight);
|
||||
}
|
||||
|
||||
export function tabModeLabel(tab: QueryTab): string {
|
||||
const { t } = useI18n();
|
||||
export function tabModeLabel(tab: QueryTab, t: Translate): string {
|
||||
if (tab.mode === "data") return t("tabs.table");
|
||||
if (tab.mode === "query") return t("tabs.sql");
|
||||
if (tab.mode === "mongo") return t("tabs.mongo");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ObjectInfo, TableInfo, TreeNode, TreeNodeType } from "@/types/database";
|
||||
|
||||
const databaseObjectNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
export function normalizeDatabaseObjectName(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
@@ -102,15 +104,120 @@ function objectIdentityKey(objectType: string, schema: string | undefined, name:
|
||||
return `${objectType}\0${(schema || "").toLowerCase()}\0${name.toLowerCase()}`;
|
||||
}
|
||||
|
||||
type PrefixSortInfo = {
|
||||
rootName: string;
|
||||
leadingSegments: number;
|
||||
};
|
||||
|
||||
function nameSegments(name: string): string[] {
|
||||
return name.toLowerCase().split("_").filter(Boolean);
|
||||
}
|
||||
|
||||
function findSubsequence(haystack: readonly string[], needle: readonly string[]): number {
|
||||
if (!needle.length || needle.length > haystack.length) return -1;
|
||||
|
||||
for (let start = 0; start <= haystack.length - needle.length; start += 1) {
|
||||
let matched = true;
|
||||
for (let offset = 0; offset < needle.length; offset += 1) {
|
||||
if (haystack[start + offset] !== needle[offset]) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) return start;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function buildPrefixSortIndex(names: readonly string[]): Map<string, PrefixSortInfo> {
|
||||
const segmentsByName = new Map<string, string[]>();
|
||||
const nameBySegmentKey = new Map<string, string>();
|
||||
for (const name of names) {
|
||||
const normalized = name.toLowerCase();
|
||||
if (segmentsByName.has(normalized)) continue;
|
||||
|
||||
const segments = nameSegments(name);
|
||||
segmentsByName.set(normalized, segments);
|
||||
const segmentKey = segments.join("_");
|
||||
if (!nameBySegmentKey.has(segmentKey)) nameBySegmentKey.set(segmentKey, normalized);
|
||||
}
|
||||
|
||||
const resolved = new Map<string, PrefixSortInfo>();
|
||||
|
||||
const resolve = (name: string): PrefixSortInfo => {
|
||||
const cached = resolved.get(name);
|
||||
if (cached) return cached;
|
||||
|
||||
const segments = segmentsByName.get(name) ?? [];
|
||||
let bestAnchor: { name: string; segmentCount: number; start: number } | undefined;
|
||||
|
||||
for (let segmentCount = segments.length - 1; segmentCount > 0 && !bestAnchor; segmentCount -= 1) {
|
||||
for (let start = 0; start <= segments.length - segmentCount; start += 1) {
|
||||
const candidateName = nameBySegmentKey.get(segments.slice(start, start + segmentCount).join("_"));
|
||||
if (!candidateName || candidateName === name) continue;
|
||||
bestAnchor = { name: candidateName, segmentCount, start };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestAnchor) {
|
||||
const info = { rootName: name, leadingSegments: 0 };
|
||||
resolved.set(name, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
const anchor = resolve(bestAnchor.name);
|
||||
const rootSegments = segmentsByName.get(anchor.rootName) ?? [];
|
||||
const rootStart = findSubsequence(segments, rootSegments);
|
||||
const info = {
|
||||
rootName: anchor.rootName,
|
||||
leadingSegments: rootStart >= 0 ? rootStart : bestAnchor.start + anchor.leadingSegments,
|
||||
};
|
||||
resolved.set(name, info);
|
||||
return info;
|
||||
};
|
||||
|
||||
for (const name of segmentsByName.keys()) resolve(name);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function comparePrefixPriorityNames(left: string, right: string, index: ReadonlyMap<string, PrefixSortInfo>): number {
|
||||
const leftName = left.toLowerCase();
|
||||
const rightName = right.toLowerCase();
|
||||
const leftInfo = index.get(leftName) ?? { rootName: leftName, leadingSegments: 0 };
|
||||
const rightInfo = index.get(rightName) ?? { rootName: rightName, leadingSegments: 0 };
|
||||
|
||||
const rootCompared = databaseObjectNameCollator.compare(leftInfo.rootName, rightInfo.rootName);
|
||||
if (rootCompared !== 0) return rootCompared;
|
||||
|
||||
if (leftInfo.leadingSegments !== rightInfo.leadingSegments) {
|
||||
return leftInfo.leadingSegments - rightInfo.leadingSegments;
|
||||
}
|
||||
|
||||
return databaseObjectNameCollator.compare(left, right);
|
||||
}
|
||||
|
||||
export function createDatabaseObjectNameComparator(names: readonly string[]): (left: string, right: string) => number {
|
||||
const index = buildPrefixSortIndex(names);
|
||||
return (left, right) => comparePrefixPriorityNames(left, right, index);
|
||||
}
|
||||
|
||||
export function sortDatabaseObjectsByPrefixPriority<T>(items: readonly T[], getName: (item: T) => string): T[] {
|
||||
const compareNames = createDatabaseObjectNameComparator(items.map(getName));
|
||||
return [...items].sort((left, right) => compareNames(getName(left), getName(right)));
|
||||
}
|
||||
|
||||
function buildPartitionTree(entries: TableTreeEntry[], connectionId: string, database: string): TreeNode[] {
|
||||
const orderedEntries = sortDatabaseObjectsByPrefixPriority(entries, (entry) => entry.node.label);
|
||||
const byKey = new Map<string, TableTreeEntry>();
|
||||
for (const entry of entries) {
|
||||
for (const entry of orderedEntries) {
|
||||
byKey.set(entry.key, entry);
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string, TableTreeEntry[]>();
|
||||
const childKeys = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
for (const entry of orderedEntries) {
|
||||
if (entry.objectType !== "TABLE" || !entry.parentName) continue;
|
||||
const parentSchema = entry.parentSchema || entry.schema;
|
||||
const parentKey = objectIdentityKey("TABLE", parentSchema, entry.parentName);
|
||||
@@ -143,7 +250,7 @@ function buildPartitionTree(entries: TableTreeEntry[], connectionId: string, dat
|
||||
return entry.node;
|
||||
};
|
||||
|
||||
return entries.filter((entry) => !childKeys.has(entry.key)).map(materialize);
|
||||
return orderedEntries.filter((entry) => !childKeys.has(entry.key)).map(materialize);
|
||||
}
|
||||
|
||||
function partitionGroupChildren(node: TreeNode): TreeNode[] {
|
||||
@@ -244,7 +351,10 @@ export function buildSimpleObjectTreeNodes({
|
||||
}
|
||||
}
|
||||
|
||||
return [...buildPartitionTree(tableEntries, connectionId, database), ...viewNodes];
|
||||
return [
|
||||
...buildPartitionTree(tableEntries, connectionId, database),
|
||||
...sortDatabaseObjectsByPrefixPriority(viewNodes, (node) => node.label),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeObjectType(type: string): "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION" {
|
||||
@@ -336,7 +446,7 @@ export function buildGroupedObjectTreeNodes({
|
||||
objects: items,
|
||||
objectType: def.objectType as "TABLE" | "VIEW",
|
||||
})
|
||||
: items.map((obj) => {
|
||||
: sortDatabaseObjectsByPrefixPriority(items, (obj) => obj.name).map((obj) => {
|
||||
const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema;
|
||||
return {
|
||||
id: `${nodeId}:${def.key}:${childSchema ? `${childSchema}:` : ""}${obj.name}`,
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface DriverStoreUsage {
|
||||
|
||||
export interface DesktopSettings {
|
||||
show_tray_icon: boolean;
|
||||
icon_theme: "default" | "black";
|
||||
}
|
||||
|
||||
export interface WebDavConfig {
|
||||
|
||||
Reference in new issue
Block a user