Merge branch 'main' of https://github.com/Abeautifulsnow/dbx into feat/ai-agent-loop-phase1
This commit is contained in:
commit
91f1206783
84 files changed
+4093
-717
No files matched your search
@@ -12,7 +12,7 @@ const props = defineProps<{
|
||||
<circle class="text-green-600 transition-[stroke-dashoffset] duration-200" cx="16" cy="16" r="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" pathLength="100" stroke-dasharray="100" :stroke-dashoffset="100 - (props.percent ?? 0)" />
|
||||
</svg>
|
||||
<span class="relative z-10 font-mono text-[10px] font-semibold leading-none text-green-700 tabular-nums">
|
||||
{{ props.percent ?? "..." }}
|
||||
{{ props.percent ?? 0 }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import { Activity, ExternalLink, Cpu, FolderOpen, MemoryStick, Search, Square, T
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import DriverInstallProgressCircle from "@/components/config/DriverInstallProgressCircle.vue";
|
||||
@@ -12,7 +13,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { countAvailableDriverUpdates } from "@/lib/agentDriverUpdateBadge";
|
||||
import type { JdbcDriverInfo, JdbcPluginStatus } from "@/types/database";
|
||||
import type { JdbcDriverInfo, JdbcMavenBundleInfo, JdbcPluginStatus } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
import type { AgentDriverInfo, DriverRuntimeInfo, DriverRuntimeSummary, DriverStoreUsage, JavaRuntimeConfig } from "@/lib/api";
|
||||
import { formatRuntimeBytes, formatRuntimeCpu, formatRuntimeUptime, runtimeHealthClass, runtimeStatusClass, runtimeStatusDotClass } from "@/lib/driverRuntimePresentation";
|
||||
@@ -63,6 +64,7 @@ const DRIVER_RUNTIME_POLL_MS = 5000;
|
||||
const OFFLINE_DRIVER_DOWNLOAD_URL = "https://dbxio.com/cn/drivers";
|
||||
|
||||
let unlisten: (() => void) | null = null;
|
||||
const lastProgressPercent = ref<number | null>(null);
|
||||
|
||||
const installedJres = computed(() => {
|
||||
const jreMap = new Map<string, boolean>();
|
||||
@@ -78,16 +80,28 @@ const progressText = computed(() => {
|
||||
const p = progress.value;
|
||||
if (!p) return "";
|
||||
if (p.step === "jre-extract") return t("driverStore.progressJreExtract");
|
||||
const label = p.step === "jre" ? t("driverStore.progressDownloadJre") : t("driverStore.progressDownloadDriver");
|
||||
if (p.step === "jdbc-plugin-extract") return t("driverStore.progressJdbcPluginExtract");
|
||||
const label = p.step === "jre" ? t("driverStore.progressDownloadJre") : p.step === "jdbc-plugin" ? t("driverStore.progressDownloadJdbcPlugin") : t("driverStore.progressDownloadDriver");
|
||||
if (!p.total) return `${label}...`;
|
||||
const pct = Math.round(((p.downloaded ?? 0) / p.total) * 100);
|
||||
const dl = formatSize(p.downloaded ?? 0);
|
||||
const total = formatSize(p.total);
|
||||
const prefix = upgradingAll.value && upgradingCurrent.value ? `[${upgradingIndex.value}/${upgradingTotal.value}] ${upgradingCurrent.value} — ` : "";
|
||||
const prefix = upgradingAll.value && upgradingCurrent.value ? `[${upgradingIndex.value}/${upgradingTotal.value}] ${upgradingCurrent.value} - ` : "";
|
||||
return `${prefix}${label} ${dl} / ${total} (${pct}%)`;
|
||||
});
|
||||
|
||||
const progressNumber = computed(() => driverInstallProgressPercent(progress.value));
|
||||
const progressNumber = computed(() => {
|
||||
const next = driverInstallProgressPercent(progress.value);
|
||||
if (next !== null) {
|
||||
lastProgressPercent.value = next;
|
||||
}
|
||||
return next ?? lastProgressPercent.value;
|
||||
});
|
||||
|
||||
function resetInstallProgress() {
|
||||
progress.value = null;
|
||||
lastProgressPercent.value = null;
|
||||
}
|
||||
|
||||
const updatableCount = computed(() => (props.updateNotificationsEnabled ? drivers.value.filter((d) => d.update_available).length : 0));
|
||||
const usageSummary = computed(() => {
|
||||
@@ -231,7 +245,7 @@ async function installDriver(dbType: string) {
|
||||
async function runDriverInstall(dbType: string) {
|
||||
const label = drivers.value.find((d) => d.db_type === dbType)?.label ?? dbType;
|
||||
installing.value = dbType;
|
||||
progress.value = null;
|
||||
resetInstallProgress();
|
||||
try {
|
||||
const blockers = await api.checkAgentUpdateBlockers([dbType]);
|
||||
if (blockers.length > 0) {
|
||||
@@ -245,7 +259,7 @@ async function runDriverInstall(dbType: string) {
|
||||
toast(t("driverStore.driverInstallFailed", { label, error: e }));
|
||||
} finally {
|
||||
installing.value = null;
|
||||
progress.value = null;
|
||||
resetInstallProgress();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +277,7 @@ async function runQueuedDriverInstalls() {
|
||||
async function upgradeAll() {
|
||||
upgradingAll.value = true;
|
||||
queuedDriverInstalls.value = [];
|
||||
progress.value = null;
|
||||
resetInstallProgress();
|
||||
try {
|
||||
const updatableDbTypes = drivers.value.filter((driver) => driver.update_available).map((driver) => driver.db_type);
|
||||
const blockers = await api.checkAgentUpdateBlockers(updatableDbTypes);
|
||||
@@ -286,7 +300,7 @@ async function upgradeAll() {
|
||||
upgradingCurrent.value = "";
|
||||
upgradingIndex.value = 0;
|
||||
upgradingTotal.value = 0;
|
||||
progress.value = null;
|
||||
resetInstallProgress();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +371,7 @@ async function importOfflineZip() {
|
||||
}
|
||||
if (!selected) return;
|
||||
importingZip.value = true;
|
||||
progress.value = null;
|
||||
resetInstallProgress();
|
||||
try {
|
||||
const count = await api.importAgentsFromZip(selected);
|
||||
await refreshAgents();
|
||||
@@ -366,7 +380,7 @@ async function importOfflineZip() {
|
||||
toast(t("driverStore.offlineImportFailed", { error: e }));
|
||||
} finally {
|
||||
importingZip.value = false;
|
||||
progress.value = null;
|
||||
resetInstallProgress();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +416,7 @@ async function importDriverJar(dbType: string) {
|
||||
|
||||
async function reinstallJre(jreKey: string) {
|
||||
reinstallingJre.value = jreKey;
|
||||
progress.value = null;
|
||||
resetInstallProgress();
|
||||
try {
|
||||
await api.reinstallJre(jreKey);
|
||||
await refreshAgents();
|
||||
@@ -411,7 +425,7 @@ async function reinstallJre(jreKey: string) {
|
||||
toast(t("driverStore.jreReinstallFailed", { jre: jreKey, error: e }));
|
||||
} finally {
|
||||
reinstallingJre.value = null;
|
||||
progress.value = null;
|
||||
resetInstallProgress();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,12 +448,45 @@ function formatSize(bytes: number): string {
|
||||
// ──────────── JDBC drivers ────────────
|
||||
|
||||
const jdbcDrivers = ref<JdbcDriverInfo[]>([]);
|
||||
const jdbcMavenBundles = ref<JdbcMavenBundleInfo[]>([]);
|
||||
const jdbcDriverSearch = ref("");
|
||||
const isLoadingJdbcDrivers = ref(false);
|
||||
const jdbcPluginStatus = ref<JdbcPluginStatus | null>(null);
|
||||
const isInstallingJdbcPlugin = ref(false);
|
||||
const isUninstallingJdbcPlugin = ref(false);
|
||||
const jdbcDriverPathInput = ref("");
|
||||
const jdbcMavenCoordinateInput = ref("");
|
||||
const jdbcMavenRepository = ref("https://repo.maven.apache.org/maven2/");
|
||||
const customJdbcMavenRepository = ref("");
|
||||
const isInstallingJdbcMavenDriver = ref(false);
|
||||
|
||||
const jdbcMavenRepositoryOptions = [
|
||||
{ label: "Maven Central", value: "https://repo.maven.apache.org/maven2/" },
|
||||
{ label: "Aliyun", value: "https://maven.aliyun.com/repository/public" },
|
||||
{ label: "Huawei Cloud", value: "https://repo.huaweicloud.com/repository/maven" },
|
||||
{ label: "Tencent Cloud", value: "https://mirrors.cloud.tencent.com/nexus/repository/maven-public" },
|
||||
{ label: "Custom", value: "custom" },
|
||||
];
|
||||
|
||||
type JdbcDriverListItem =
|
||||
| {
|
||||
kind: "manual";
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
source: string;
|
||||
size: number;
|
||||
driver: JdbcDriverInfo;
|
||||
}
|
||||
| {
|
||||
kind: "maven";
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
source: string;
|
||||
size: number;
|
||||
bundle: JdbcMavenBundleInfo;
|
||||
};
|
||||
|
||||
const filteredAgentDrivers = computed(() => {
|
||||
const query = agentDriverSearch.value.trim().toLowerCase();
|
||||
@@ -447,10 +494,34 @@ const filteredAgentDrivers = computed(() => {
|
||||
return drivers.value.filter((driver) => [driver.label, driver.db_type, driver.version, driver.installed_version, driver.jre].filter(Boolean).join(" ").toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const jdbcDriverListItems = computed<JdbcDriverListItem[]>(() => {
|
||||
const bundleItems = jdbcMavenBundles.value.map((bundle) => ({
|
||||
kind: "maven" as const,
|
||||
id: `maven:${bundle.id}`,
|
||||
title: bundle.coordinate,
|
||||
subtitle: `${bundle.artifacts.length} JARs - ${bundle.repositories.join(", ")}`,
|
||||
source: t("driverStore.jdbcSourceMaven"),
|
||||
size: bundle.artifacts.reduce((total, artifact) => total + Number(artifact.size || 0), 0),
|
||||
bundle,
|
||||
}));
|
||||
const manualItems = jdbcDrivers.value
|
||||
.filter((driver) => !driver.bundle_id)
|
||||
.map((driver) => ({
|
||||
kind: "manual" as const,
|
||||
id: `manual:${driver.path}`,
|
||||
title: driver.name,
|
||||
subtitle: driver.path,
|
||||
source: t("driverStore.jdbcSourceManual"),
|
||||
size: driver.size,
|
||||
driver,
|
||||
}));
|
||||
return [...bundleItems, ...manualItems].sort((a, b) => a.title.localeCompare(b.title));
|
||||
});
|
||||
|
||||
const filteredJdbcDrivers = computed(() => {
|
||||
const query = jdbcDriverSearch.value.trim().toLowerCase();
|
||||
if (!query) return jdbcDrivers.value;
|
||||
return jdbcDrivers.value.filter((driver) => [driver.name, driver.path, String(driver.size)].join(" ").toLowerCase().includes(query));
|
||||
if (!query) return jdbcDriverListItems.value;
|
||||
return jdbcDriverListItems.value.filter((item) => [item.title, item.subtitle, String(item.size)].join(" ").toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
@@ -568,7 +639,9 @@ function jreUsageLabel(key: string) {
|
||||
async function loadJdbcDrivers() {
|
||||
isLoadingJdbcDrivers.value = true;
|
||||
try {
|
||||
jdbcDrivers.value = await api.listJdbcDrivers();
|
||||
const [drivers, bundles] = await Promise.all([api.listJdbcDrivers(), api.listJdbcMavenBundles()]);
|
||||
jdbcDrivers.value = drivers;
|
||||
jdbcMavenBundles.value = bundles;
|
||||
} catch (e: any) {
|
||||
toast(String(e?.message || e), 5000);
|
||||
} finally {
|
||||
@@ -597,6 +670,7 @@ async function loadJdbcPluginStatus() {
|
||||
async function installJdbcPlugin() {
|
||||
if (isInstallingJdbcPlugin.value) return;
|
||||
isInstallingJdbcPlugin.value = true;
|
||||
resetInstallProgress();
|
||||
try {
|
||||
jdbcPluginStatus.value = await api.installJdbcPlugin();
|
||||
emitDriverUpdateCount();
|
||||
@@ -606,6 +680,7 @@ async function installJdbcPlugin() {
|
||||
toast(String(e?.message || e), 5000);
|
||||
} finally {
|
||||
isInstallingJdbcPlugin.value = false;
|
||||
resetInstallProgress();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,6 +772,24 @@ async function importJdbcDriverPathInput() {
|
||||
await importJdbcDriverPaths(paths);
|
||||
}
|
||||
|
||||
async function installJdbcMavenDriver() {
|
||||
const coordinate = jdbcMavenCoordinateInput.value.trim();
|
||||
const repository = jdbcMavenRepository.value === "custom" ? customJdbcMavenRepository.value.trim() : jdbcMavenRepository.value;
|
||||
if (!coordinate || !repository || isInstallingJdbcMavenDriver.value) return;
|
||||
isInstallingJdbcMavenDriver.value = true;
|
||||
try {
|
||||
jdbcDrivers.value = await api.installJdbcDriverFromMaven(coordinate, [repository]);
|
||||
jdbcMavenBundles.value = await api.listJdbcMavenBundles();
|
||||
jdbcMavenCoordinateInput.value = "";
|
||||
void loadDriverStoreUsage();
|
||||
toast(t("driverStore.jdbcMavenInstallSuccess"));
|
||||
} catch (e: any) {
|
||||
toast(String(e?.message || e), 8000);
|
||||
} finally {
|
||||
isInstallingJdbcMavenDriver.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteJdbcDriver(path: string) {
|
||||
try {
|
||||
jdbcDrivers.value = await api.deleteJdbcDriver(path);
|
||||
@@ -707,6 +800,17 @@ async function deleteJdbcDriver(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteJdbcMavenBundle(bundleId: string) {
|
||||
try {
|
||||
jdbcDrivers.value = await api.deleteJdbcMavenBundle(bundleId);
|
||||
jdbcMavenBundles.value = await api.listJdbcMavenBundles();
|
||||
void loadDriverStoreUsage();
|
||||
toast(t("settings.jdbcDeleteSuccess"));
|
||||
} catch (e: any) {
|
||||
toast(String(e?.message || e), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────── Lifecycle ────────────
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -961,6 +1065,7 @@ watch(driverStoreTab, (tab) => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<DriverInstallProgressCircle v-if="isInstallingJdbcPlugin" :percent="progressNumber" :title="progressTitle(t('driverStore.progressDownloadJdbcPlugin'))" />
|
||||
<span v-if="jdbcPluginStatus?.installed" class="text-xs" :class="jdbcPluginStatus.compatible ? 'text-green-600' : 'text-destructive'">
|
||||
{{
|
||||
jdbcPluginStatus.compatible
|
||||
@@ -1007,26 +1112,50 @@ watch(driverStoreTab, (tab) => {
|
||||
{{ t("settings.jdbcImport") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid gap-2 md:grid-cols-[minmax(0,1fr)_180px_auto]">
|
||||
<Input v-model="jdbcMavenCoordinateInput" class="h-8 text-xs" :placeholder="t('driverStore.jdbcMavenCoordinatePlaceholder')" @keydown.enter.prevent="installJdbcMavenDriver" />
|
||||
<Select v-model="jdbcMavenRepository">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="repo in jdbcMavenRepositoryOptions" :key="repo.value" :value="repo.value">
|
||||
{{ repo.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button class="h-8 shrink-0 rounded-full" :disabled="!jdbcMavenCoordinateInput.trim() || isInstallingJdbcMavenDriver || (jdbcMavenRepository === 'custom' && !customJdbcMavenRepository.trim())" @click="installJdbcMavenDriver">
|
||||
<Loader2 v-if="isInstallingJdbcMavenDriver" class="h-4 w-4 animate-spin" />
|
||||
<Download v-else class="h-4 w-4" />
|
||||
{{ t("driverStore.jdbcMavenInstall") }}
|
||||
</Button>
|
||||
</div>
|
||||
<Input v-if="jdbcMavenRepository === 'custom'" v-model="customJdbcMavenRepository" class="h-8 text-xs" placeholder="https://repo.example.com/repository/maven-public" @keydown.enter.prevent="installJdbcMavenDriver" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<div v-if="isLoadingJdbcDrivers" class="p-4 text-sm text-muted-foreground">
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="jdbcDrivers.length === 0" class="p-4 text-sm text-muted-foreground">
|
||||
<div v-else-if="jdbcDriverListItems.length === 0" class="p-4 text-sm text-muted-foreground">
|
||||
{{ t("settings.jdbcNoDrivers") }}
|
||||
</div>
|
||||
<div v-else-if="filteredJdbcDrivers.length === 0" class="p-4 text-sm text-muted-foreground">
|
||||
{{ t("driverStore.noMatchingDrivers") }}
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="driver in filteredJdbcDrivers" :key="driver.path" class="flex items-center gap-3 p-3">
|
||||
<div v-for="item in filteredJdbcDrivers" :key="item.id" class="flex items-center gap-3 p-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ driver.name }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ driver.path }}</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="truncate text-sm font-medium">{{ item.title }}</div>
|
||||
<Badge variant="outline" class="h-5 shrink-0 rounded-full px-2 text-[10px] font-medium">
|
||||
{{ item.source }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-xs text-muted-foreground">{{ formatBytes(driver.size) }}</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 rounded-full" @click="deleteJdbcDriver(driver.path)">
|
||||
<div class="shrink-0 text-xs text-muted-foreground">{{ formatBytes(item.size) }}</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 rounded-full" @click="item.kind === 'maven' ? deleteJdbcMavenBundle(item.bundle.id) : deleteJdbcDriver(item.driver.path)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,15 @@ import { uuid } from "@/lib/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
@@ -33,6 +35,11 @@ type DbCategory = { key: string; title: string; options: DbOption[] };
|
||||
type DialogStep = "select" | "config";
|
||||
type DbPickerView = "icon" | "list";
|
||||
type ConfigTab = "connection" | "advanced" | "tls" | "transport";
|
||||
type JdbcDriverSelectItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
type LegacyTransportFields = {
|
||||
ssh_enabled?: boolean;
|
||||
@@ -246,8 +253,10 @@ const customDriverName = ref("");
|
||||
const mongoUseUrl = ref(false);
|
||||
const jdbcDriverPathsInput = ref("");
|
||||
const jdbcDrivers = ref<JdbcDriverInfo[]>([]);
|
||||
const jdbcMavenBundles = ref<JdbcMavenBundleInfo[]>([]);
|
||||
const agentDrivers = ref<AgentDriverInstallState[]>([]);
|
||||
const selectedJdbcDriverPath = ref("");
|
||||
const jdbcManualClasspathOpen = ref(false);
|
||||
const connectionUrlInput = ref("");
|
||||
const oceanbaseSubMode = ref<"mysql" | "oracle">("mysql");
|
||||
const h2ConnectionMode = ref<H2ConnectionMode>("file");
|
||||
@@ -270,6 +279,31 @@ const isPresetColor = (color: string | undefined) => colorOptions.some((c) => c.
|
||||
const customColorInput = ref("");
|
||||
const customColorOpen = ref(false);
|
||||
|
||||
const jdbcDriverSelectItems = computed<JdbcDriverSelectItem[]>(() => {
|
||||
const bundles = jdbcMavenBundles.value.map((bundle) => ({
|
||||
id: `maven:${bundle.id}`,
|
||||
label: bundle.coordinate,
|
||||
paths: bundle.artifacts.map((artifact) => artifact.path),
|
||||
}));
|
||||
const manual = jdbcDrivers.value
|
||||
.filter((driver) => !driver.bundle_id)
|
||||
.map((driver) => ({
|
||||
id: `manual:${driver.path}`,
|
||||
label: driver.name,
|
||||
paths: [driver.path],
|
||||
}));
|
||||
return [...bundles, ...manual].sort((left, right) => left.label.localeCompare(right.label));
|
||||
});
|
||||
|
||||
const jdbcDriverSelectItemById = computed(() => new Map(jdbcDriverSelectItems.value.map((item) => [item.id, item])));
|
||||
const jdbcManualClasspathCount = computed(
|
||||
() =>
|
||||
jdbcDriverPathsInput.value
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean).length,
|
||||
);
|
||||
|
||||
function applyCustomColor(value: string) {
|
||||
form.value.color = value;
|
||||
customColorInput.value = value;
|
||||
@@ -727,7 +761,7 @@ const iconTypeMap: Record<string, string> = {
|
||||
custom_postgres: "postgres",
|
||||
};
|
||||
|
||||
const dbOptions = [
|
||||
const dbOptions: DbOption[] = [
|
||||
{ value: "postgres", label: "PostgreSQL" },
|
||||
{ value: "mysql", label: "MySQL" },
|
||||
{ value: "mongodb", label: "MongoDB" },
|
||||
@@ -787,12 +821,19 @@ const dbOptions = [
|
||||
{ value: "influxdb", label: "InfluxDB" },
|
||||
{ value: "iris", label: "IRIS" },
|
||||
{ value: "jdbc", label: "JDBC" },
|
||||
{ value: "custom_mysql", label: "Custom (MySQL)" },
|
||||
{ value: "custom_postgres", label: "Custom (PostgreSQL)" },
|
||||
];
|
||||
|
||||
const dbCategories = computed<DbCategory[]>(() => [{ key: "all", title: "", options: dbOptions }]);
|
||||
|
||||
function matchesDbOption(option: DbOption, keyword: string, categoryTitle = "") {
|
||||
const profile = driverProfiles[option.value];
|
||||
return [option.label, option.value, profile?.label, profile?.type, categoryTitle].some((value) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
}
|
||||
|
||||
const filteredDbCategories = computed<DbCategory[]>(() => {
|
||||
const keyword = dbSearchQuery.value.trim().toLowerCase();
|
||||
if (!keyword) return dbCategories.value;
|
||||
@@ -800,14 +841,7 @@ const filteredDbCategories = computed<DbCategory[]>(() => {
|
||||
return dbCategories.value
|
||||
.map((category) => ({
|
||||
...category,
|
||||
options: category.options.filter((option) => {
|
||||
const profile = driverProfiles[option.value];
|
||||
return [option.label, option.value, profile?.label, profile?.type, category.title].some((value) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
}),
|
||||
options: category.options.filter((option) => matchesDbOption(option, keyword, category.title)),
|
||||
}))
|
||||
.filter((category) => category.options.length > 0);
|
||||
});
|
||||
@@ -1939,9 +1973,12 @@ async function browseJdbcDriverPaths() {
|
||||
async function loadJdbcDrivers() {
|
||||
if (!isDesktop) return;
|
||||
try {
|
||||
jdbcDrivers.value = await api.listJdbcDrivers();
|
||||
const [drivers, bundles] = await Promise.all([api.listJdbcDrivers(), api.listJdbcMavenBundles()]);
|
||||
jdbcDrivers.value = drivers;
|
||||
jdbcMavenBundles.value = bundles;
|
||||
} catch {
|
||||
jdbcDrivers.value = [];
|
||||
jdbcMavenBundles.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1962,18 +1999,21 @@ async function loadAgentDrivers() {
|
||||
}
|
||||
}
|
||||
|
||||
function addJdbcDriverPath(path: string) {
|
||||
function addJdbcDriverPaths(paths: string[]) {
|
||||
const existing = jdbcDriverPathsInput.value
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
jdbcDriverPathsInput.value = Array.from(new Set([...existing, path])).join("\n");
|
||||
jdbcDriverPathsInput.value = Array.from(new Set([...existing, ...paths])).join("\n");
|
||||
}
|
||||
|
||||
function onJdbcDriverSelect(path: any) {
|
||||
if (typeof path !== "string" || !path) return;
|
||||
selectedJdbcDriverPath.value = path;
|
||||
addJdbcDriverPath(path);
|
||||
function onJdbcDriverSelect(id: any) {
|
||||
if (typeof id !== "string" || !id) return;
|
||||
const item = jdbcDriverSelectItemById.value.get(id);
|
||||
if (!item) return;
|
||||
selectedJdbcDriverPath.value = id;
|
||||
addJdbcDriverPaths(item.paths);
|
||||
jdbcManualClasspathOpen.value = false;
|
||||
}
|
||||
|
||||
function openExternalUrl(url: string) {
|
||||
@@ -2014,7 +2054,7 @@ function openExternalUrl(url: string) {
|
||||
<div class="max-h-[58vh] space-y-5 overflow-y-auto pr-2">
|
||||
<section v-for="category in filteredDbCategories" :key="category.key" class="space-y-2">
|
||||
<div class="flex items-center">
|
||||
<h3 class="text-sm font-medium">{{ category.title }}</h3>
|
||||
<h3 v-if="category.title" class="text-sm font-medium">{{ category.title }}</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="dbPickerView === 'icon'" class="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-5">
|
||||
@@ -2200,24 +2240,29 @@ function openExternalUrl(url: string) {
|
||||
<Label class="text-right">{{ t("connection.password") }}</Label>
|
||||
<Input v-model="form.password" type="password" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.jdbcDriverClass") }}</Label>
|
||||
<Input v-model="form.jdbc_driver_class" class="col-span-3" :placeholder="t('connection.jdbcDriverClassPlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-right mt-2">{{ t("connection.jdbcDriverPaths") }}</Label>
|
||||
<div class="col-span-3 space-y-2">
|
||||
<Select v-if="jdbcDrivers.length > 0" :model-value="selectedJdbcDriverPath" @update:model-value="onJdbcDriverSelect">
|
||||
<Select v-if="jdbcDriverSelectItems.length > 0" :model-value="selectedJdbcDriverPath" @update:model-value="onJdbcDriverSelect">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('connection.jdbcDriverSelectPlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="driver in jdbcDrivers" :key="driver.path" :value="driver.path">
|
||||
{{ driver.name }}
|
||||
<SelectItem v-for="driver in jdbcDriverSelectItems" :key="driver.id" :value="driver.id">
|
||||
{{ driver.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="flex items-start gap-1">
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="truncate text-xs font-medium">{{ t("connection.jdbcManualClasspath") }}</div>
|
||||
<Badge variant="outline" class="h-5 shrink-0 rounded-full px-2 text-[10px] font-medium">
|
||||
{{ t("connection.jdbcManualClasspathCount", { count: jdbcManualClasspathCount }) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Switch v-model="jdbcManualClasspathOpen" />
|
||||
</div>
|
||||
<div v-if="jdbcManualClasspathOpen" class="flex items-start gap-1">
|
||||
<textarea
|
||||
v-model="jdbcDriverPathsInput"
|
||||
class="flex min-h-12 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
@@ -2234,6 +2279,10 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t("connection.jdbcDriverClass") }}</Label>
|
||||
<Input v-model="form.jdbc_driver_class" class="col-span-3" :placeholder="t('connection.jdbcDriverClassPlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 space-y-2">
|
||||
|
||||
+181
-167
@@ -9,14 +9,17 @@ import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
|
||||
import DataGrid from "@/components/grid/DataGrid.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import { clampSearchSplitWidth } from "@/lib/dataGridSearchSplit";
|
||||
import { buildDocumentFilterCondition, combineDocumentFilterConditions, currentDocumentFilterJson, defaultDocumentFilterRule, documentFilterModeNeedsValue, documentFilterModeOptions, documentStoreProviderFor, type DocumentFilterMode, type DocumentFilterRule } from "@/lib/documentStoreProvider";
|
||||
import { normalizeResultPageSize } from "@/lib/paginationPageSize";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import JsonEditNode from "./JsonEditNode.vue";
|
||||
import type { EditNode } from "@/types/editor";
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
@@ -60,22 +63,16 @@ const tableFindPaneWidth = ref<number | null>(null);
|
||||
const isResizingTableSearchSplit = ref(false);
|
||||
let tableSearchSplitStartX = 0;
|
||||
let tableSearchSplitStartWidth = 0;
|
||||
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
|
||||
|
||||
const tableFindPaneStyle = computed(() => {
|
||||
if (tableFindPaneWidth.value == null) return {};
|
||||
return { flex: `0 0 ${tableFindPaneWidth.value}px` };
|
||||
});
|
||||
const documentStoreLabels = computed(() =>
|
||||
props.databaseType === "elasticsearch"
|
||||
? {
|
||||
documentsLabel: "Documents",
|
||||
queryPreview: `${props.collection}/_search`,
|
||||
}
|
||||
: {
|
||||
documentsLabel: t("mongo.documents", { count: total.value }),
|
||||
queryPreview: mongoQueryPreview.value,
|
||||
},
|
||||
);
|
||||
const documentStoreLabels = computed(() => ({
|
||||
documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value, t }),
|
||||
queryPreview: documentQueryPreview.value,
|
||||
}));
|
||||
|
||||
type PendingDelete = { kind: "document"; index: number } | { kind: "field"; index: number; name: string };
|
||||
type LocalFilterSummary = {
|
||||
@@ -84,28 +81,9 @@ type LocalFilterSummary = {
|
||||
values: string[];
|
||||
hiddenValueCount: number;
|
||||
};
|
||||
type MongoFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "less-than" | "is-null" | "is-not-null";
|
||||
type MongoFilterRule = {
|
||||
id: string;
|
||||
fieldName: string;
|
||||
mode: MongoFilterMode;
|
||||
rawValue: string;
|
||||
conjunction: "AND" | "OR";
|
||||
};
|
||||
|
||||
const mongoFilterModeOptions: Array<{ value: MongoFilterMode; labelKey: string }> = [
|
||||
{ value: "equals", labelKey: "grid.filterBuilderEquals" },
|
||||
{ value: "not-equals", labelKey: "grid.filterBuilderNotEquals" },
|
||||
{ value: "like", labelKey: "grid.filterBuilderContains" },
|
||||
{ value: "not-like", labelKey: "grid.filterBuilderNotContains" },
|
||||
{ value: "greater-than", labelKey: "grid.filterBuilderGreaterThan" },
|
||||
{ value: "less-than", labelKey: "grid.filterBuilderLessThan" },
|
||||
{ value: "is-null", labelKey: "grid.filterBuilderIsNull" },
|
||||
{ value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" },
|
||||
];
|
||||
const mongoFilterBuilderOpen = ref(false);
|
||||
const mongoFilterRules = ref<MongoFilterRule[]>([]);
|
||||
const appliedMongoFilter = ref<Record<string, unknown> | null>(null);
|
||||
const documentFilterBuilderOpen = ref(false);
|
||||
const documentFilterRules = ref<DocumentFilterRule[]>([]);
|
||||
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
|
||||
|
||||
const pendingDelete = ref<PendingDelete | null>(null);
|
||||
|
||||
@@ -165,139 +143,77 @@ const gridResult = computed<QueryResult>(() => {
|
||||
|
||||
return { columns, rows, affected_rows: 0, execution_time_ms: 0, truncated: false };
|
||||
});
|
||||
const mongoFilterFieldOptions = computed(() => gridResult.value.columns);
|
||||
const mongoStructuredFilterCount = computed(() => (appliedMongoFilter.value ? 1 : 0));
|
||||
const documentFilterFieldOptions = computed(() => gridResult.value.columns);
|
||||
const documentStructuredFilterCount = computed(() => (appliedDocumentFilter.value ? 1 : 0));
|
||||
|
||||
function defaultMongoFilterRule(): MongoFilterRule {
|
||||
return {
|
||||
id: uuid(),
|
||||
fieldName: mongoFilterFieldOptions.value[0] ?? "",
|
||||
mode: "equals",
|
||||
rawValue: "",
|
||||
conjunction: "AND",
|
||||
};
|
||||
function createDocumentFilterRule(): DocumentFilterRule {
|
||||
return defaultDocumentFilterRule(uuid(), documentFilterFieldOptions.value[0] ?? "");
|
||||
}
|
||||
|
||||
function ensureMongoFilterRule() {
|
||||
if (mongoFilterRules.value.length === 0 && mongoFilterFieldOptions.value.length > 0) {
|
||||
mongoFilterRules.value = [defaultMongoFilterRule()];
|
||||
function ensureDocumentFilterRule() {
|
||||
if (documentFilterRules.value.length === 0 && documentFilterFieldOptions.value.length > 0) {
|
||||
documentFilterRules.value = [createDocumentFilterRule()];
|
||||
}
|
||||
}
|
||||
|
||||
function addMongoFilterRule() {
|
||||
ensureMongoFilterRule();
|
||||
mongoFilterRules.value = [...mongoFilterRules.value, defaultMongoFilterRule()];
|
||||
function addDocumentFilterRule() {
|
||||
ensureDocumentFilterRule();
|
||||
documentFilterRules.value = [...documentFilterRules.value, createDocumentFilterRule()];
|
||||
}
|
||||
|
||||
function removeMongoFilterRule(ruleId: string) {
|
||||
mongoFilterRules.value = mongoFilterRules.value.filter((rule) => rule.id !== ruleId);
|
||||
if (mongoFilterRules.value.length === 0) appliedMongoFilter.value = null;
|
||||
function removeDocumentFilterRule(ruleId: string) {
|
||||
documentFilterRules.value = documentFilterRules.value.filter((rule) => rule.id !== ruleId);
|
||||
if (documentFilterRules.value.length === 0) appliedDocumentFilter.value = null;
|
||||
}
|
||||
|
||||
function updateMongoFilterRule(ruleId: string, patch: Partial<MongoFilterRule>) {
|
||||
mongoFilterRules.value = mongoFilterRules.value.map((rule) => {
|
||||
function updateDocumentFilterRule(ruleId: string, patch: Partial<DocumentFilterRule>) {
|
||||
documentFilterRules.value = documentFilterRules.value.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
const next = { ...rule, ...patch };
|
||||
if (!mongoFilterModeNeedsValue(next.mode)) next.rawValue = "";
|
||||
if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = "";
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function resetMongoFilterBuilder() {
|
||||
appliedMongoFilter.value = null;
|
||||
mongoFilterRules.value = mongoFilterFieldOptions.value.length > 0 ? [defaultMongoFilterRule()] : [];
|
||||
function resetDocumentFilterBuilder() {
|
||||
appliedDocumentFilter.value = null;
|
||||
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
|
||||
}
|
||||
|
||||
function mongoFilterModeNeedsValue(mode: MongoFilterMode): boolean {
|
||||
return mode !== "is-null" && mode !== "is-not-null";
|
||||
function currentDocumentFilter(): string | undefined {
|
||||
return currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value);
|
||||
}
|
||||
|
||||
function parseMongoFilterValue(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "";
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function mongoConditionForRule(rule: MongoFilterRule): Record<string, unknown> | null {
|
||||
if (!rule.fieldName) return null;
|
||||
if (mongoFilterModeNeedsValue(rule.mode) && !rule.rawValue.trim()) return null;
|
||||
const value = mongoFilterModeNeedsValue(rule.mode) ? parseMongoFilterValue(rule.rawValue) : null;
|
||||
switch (rule.mode) {
|
||||
case "equals":
|
||||
return { [rule.fieldName]: value };
|
||||
case "not-equals":
|
||||
return { [rule.fieldName]: { $ne: value } };
|
||||
case "like":
|
||||
return { [rule.fieldName]: { $regex: String(value), $options: "i" } };
|
||||
case "not-like":
|
||||
return { [rule.fieldName]: { $not: { $regex: String(value), $options: "i" } } };
|
||||
case "greater-than":
|
||||
return { [rule.fieldName]: { $gt: value } };
|
||||
case "less-than":
|
||||
return { [rule.fieldName]: { $lt: value } };
|
||||
case "is-null":
|
||||
return { [rule.fieldName]: null };
|
||||
case "is-not-null":
|
||||
return { [rule.fieldName]: { $ne: null } };
|
||||
}
|
||||
}
|
||||
|
||||
function combineMongoConditions(conditions: Record<string, unknown>[], rules: MongoFilterRule[]): Record<string, unknown> | null {
|
||||
if (conditions.length === 0) return null;
|
||||
let result = conditions[0];
|
||||
for (let i = 1; i < conditions.length; i++) {
|
||||
const operator = rules[i]?.conjunction === "OR" ? "$or" : "$and";
|
||||
result = { [operator]: [result, conditions[i]] };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMongoFilterInput(): Record<string, unknown> {
|
||||
const trimmed = filterInput.value.trim();
|
||||
if (!trimmed) return {};
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function currentMongoFilter(): string | undefined {
|
||||
const manual = parseMongoFilterInput();
|
||||
const structured = appliedMongoFilter.value;
|
||||
const filter = structured ? (Object.keys(manual).length ? { $and: [manual, structured] } : structured) : manual;
|
||||
return Object.keys(filter).length ? JSON.stringify(filter) : undefined;
|
||||
}
|
||||
|
||||
const mongoQueryPreview = computed(() => {
|
||||
const documentQueryPreview = computed(() => {
|
||||
let filter = "{}";
|
||||
try {
|
||||
filter = currentMongoFilter() ?? "{}";
|
||||
filter = currentDocumentFilter() ?? "{}";
|
||||
} catch {
|
||||
filter = filterInput.value.trim() || "{}";
|
||||
}
|
||||
const sort = sortInput.value.trim();
|
||||
const parts = [`db.${props.collection}.find(${filter})`];
|
||||
if (sort) parts.push(`.sort(${sort})`);
|
||||
parts.push(`.skip(${page.value * pageSize.value}).limit(${pageSize.value})`);
|
||||
return parts.join("");
|
||||
return documentStoreProvider.value.queryPreview({
|
||||
collection: props.collection,
|
||||
filterJson: filter,
|
||||
sortJson: sortInput.value.trim(),
|
||||
skip: page.value * pageSize.value,
|
||||
limit: pageSize.value,
|
||||
});
|
||||
});
|
||||
|
||||
async function applyMongoStructuredFilters() {
|
||||
const items = mongoFilterRules.value.map((rule) => ({ rule, condition: mongoConditionForRule(rule) })).filter((item): item is { rule: MongoFilterRule; condition: Record<string, unknown> } => !!item.condition);
|
||||
const structured = combineMongoConditions(
|
||||
async function applyDocumentStructuredFilters() {
|
||||
const items = documentFilterRules.value.map((rule) => ({ rule, condition: buildDocumentFilterCondition(rule) })).filter((item): item is { rule: DocumentFilterRule; condition: Record<string, unknown> } => !!item.condition);
|
||||
const structured = combineDocumentFilterConditions(
|
||||
items.map((item) => item.condition),
|
||||
items.map((item) => item.rule),
|
||||
);
|
||||
appliedMongoFilter.value = structured;
|
||||
mongoFilterBuilderOpen.value = false;
|
||||
appliedDocumentFilter.value = structured;
|
||||
documentFilterBuilderOpen.value = false;
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function clearMongoFilters(clearLocalFilter?: (columnIndex?: number) => void) {
|
||||
appliedMongoFilter.value = null;
|
||||
resetMongoFilterBuilder();
|
||||
function clearDocumentFilters(clearLocalFilter?: (columnIndex?: number) => void) {
|
||||
appliedDocumentFilter.value = null;
|
||||
resetDocumentFilterBuilder();
|
||||
clearLocalFilter?.();
|
||||
applyFilter();
|
||||
}
|
||||
@@ -342,13 +258,117 @@ async function gridSave(changes: { dirtyRows: Map<number, Map<number, string | n
|
||||
await load();
|
||||
}
|
||||
|
||||
function formatMongoValue(val: unknown): string {
|
||||
if (val === null || val === undefined) return "null";
|
||||
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
||||
if (typeof val === "string") return JSON.stringify(val);
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
|
||||
function mongoIdPreview(val: unknown): string {
|
||||
if (val === null || val === undefined) return "null";
|
||||
if (typeof val === "string" && /^[a-fA-F0-9]{24}$/.test(val)) return `ObjectId("${val}")`;
|
||||
return formatMongoValue(val);
|
||||
}
|
||||
|
||||
function buildUpdateDoc(changes: Map<number, string | number | boolean | null>, columns: string[]): Record<string, unknown> {
|
||||
const setFields: Record<string, unknown> = {};
|
||||
const unsetFields: Record<string, unknown> = {};
|
||||
for (const [colIdx, newVal] of changes) {
|
||||
const col = columns[colIdx];
|
||||
if (!col || col === "_id") continue;
|
||||
if (newVal === null) {
|
||||
unsetFields[col] = "";
|
||||
} else if (typeof newVal === "string") {
|
||||
try {
|
||||
setFields[col] = JSON.parse(newVal);
|
||||
} catch {
|
||||
setFields[col] = newVal;
|
||||
}
|
||||
} else {
|
||||
setFields[col] = newVal;
|
||||
}
|
||||
}
|
||||
const doc: Record<string, unknown> = {};
|
||||
if (Object.keys(setFields).length > 0) doc.$set = setFields;
|
||||
if (Object.keys(unsetFields).length > 0) doc.$unset = unsetFields;
|
||||
return doc;
|
||||
}
|
||||
|
||||
function buildNewDoc(newRow: (string | number | boolean | null)[], columns: string[]): Record<string, unknown> {
|
||||
const doc: Record<string, unknown> = {};
|
||||
for (let ci = 0; ci < columns.length; ci++) {
|
||||
const col = columns[ci];
|
||||
if (!col || col === "_id") continue;
|
||||
const val = newRow[ci];
|
||||
if (val === null) continue;
|
||||
if (typeof val === "string") {
|
||||
try {
|
||||
doc[col] = JSON.parse(val);
|
||||
} catch {
|
||||
doc[col] = val;
|
||||
}
|
||||
} else {
|
||||
doc[col] = val;
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function previewDocumentChanges(changes: { dirtyRows: Map<number, Map<number, string | number | boolean | null>>; deletedRows: Set<number>; newRows: (string | number | boolean | null)[][]; columns: string[]; rows: (string | number | boolean | null)[][] }): Promise<string[]> {
|
||||
const { dirtyRows, deletedRows, newRows, columns, rows } = changes;
|
||||
const idColIdx = columns.indexOf("_id");
|
||||
const stmts: string[] = [];
|
||||
const coll = props.collection;
|
||||
const isEs = documentStoreProvider.value.kind === "elasticsearch";
|
||||
|
||||
for (const [rowIdx, dirtyCols] of dirtyRows) {
|
||||
const row = rows[rowIdx];
|
||||
const id = row?.[idColIdx];
|
||||
if (id == null) continue;
|
||||
const updateDoc = buildUpdateDoc(dirtyCols, columns);
|
||||
if (isEs) {
|
||||
stmts.push(`POST /${coll}/_update/${JSON.stringify(String(id))}\n${JSON.stringify({ doc: updateDoc.$set ?? updateDoc }, null, 2)}`);
|
||||
} else {
|
||||
stmts.push(`db.${coll}.updateOne({_id: ${mongoIdPreview(id)}}, ${JSON.stringify(updateDoc)})`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rowIdx of deletedRows) {
|
||||
const row = rows[rowIdx];
|
||||
const id = row?.[idColIdx];
|
||||
if (id == null) continue;
|
||||
if (isEs) {
|
||||
stmts.push(`DELETE /${coll}/_doc/${JSON.stringify(String(id))}`);
|
||||
} else {
|
||||
stmts.push(`db.${coll}.deleteOne({_id: ${mongoIdPreview(id)}})`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const newRow of newRows) {
|
||||
const doc = buildNewDoc(newRow, columns);
|
||||
if (isEs) {
|
||||
stmts.push(`POST /${coll}/_doc\n${JSON.stringify(doc, null, 2)}`);
|
||||
} else {
|
||||
stmts.push(`db.${coll}.insertOne(${JSON.stringify(doc)})`);
|
||||
}
|
||||
}
|
||||
|
||||
return stmts;
|
||||
}
|
||||
|
||||
const customSaveHandler = computed<CustomSaveHandler>(() => ({
|
||||
save: gridSave,
|
||||
preview: previewDocumentChanges,
|
||||
}));
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const filter = currentMongoFilter();
|
||||
const filter = currentDocumentFilter();
|
||||
const sort = sortInput.value.trim() || undefined;
|
||||
const result = await api.mongoFindDocuments(props.connectionId, props.database, props.collection, page.value * pageSize.value, pageSize.value, filter, sort);
|
||||
const result = await api.documentFindDocuments(props.connectionId, props.database, props.collection, page.value * pageSize.value, pageSize.value, filter, sort);
|
||||
const nextDocuments = result.documents.map(asRecord);
|
||||
documents.value = nextDocuments;
|
||||
if (nextDocuments.length > 0) {
|
||||
@@ -382,11 +402,7 @@ function paginate(offset: number, limit: number) {
|
||||
}
|
||||
|
||||
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
|
||||
if (direction) {
|
||||
sortInput.value = JSON.stringify({ [column]: direction === "asc" ? 1 : -1 });
|
||||
} else {
|
||||
sortInput.value = "";
|
||||
}
|
||||
sortInput.value = documentStoreProvider.value.sortInputForColumn(column, direction);
|
||||
page.value = 0;
|
||||
load();
|
||||
}
|
||||
@@ -778,7 +794,7 @@ function resetTableSearchSplitWidth() {
|
||||
:result="gridResult"
|
||||
context="results"
|
||||
editable
|
||||
:custom-save="gridSave"
|
||||
:custom-save-handler="customSaveHandler"
|
||||
:loading="loading"
|
||||
:sql="documentStoreLabels.queryPreview"
|
||||
:page-offset="page * pageSize"
|
||||
@@ -791,24 +807,24 @@ function resetTableSearchSplitWidth() {
|
||||
<template #search-bar="{ localFilterCount, hasLocalColumnFilters, localFilterSummaries, clearLocalFilter }: { localFilterCount: number; hasLocalColumnFilters: boolean; localFilterSummaries: LocalFilterSummary[]; clearLocalFilter: (columnIndex?: number) => void }">
|
||||
<div ref="tableSearchSplitContainerRef" class="flex flex-1 min-w-0">
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0" :style="tableFindPaneStyle">
|
||||
<Popover v-model:open="mongoFilterBuilderOpen">
|
||||
<Popover v-model:open="documentFilterBuilderOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex h-5 w-5 shrink-0 items-center justify-center rounded border text-[11px] font-medium transition-colors"
|
||||
:class="hasLocalColumnFilters || appliedMongoFilter ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
|
||||
@click="ensureMongoFilterRule"
|
||||
:class="hasLocalColumnFilters || appliedDocumentFilter ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
|
||||
@click="ensureDocumentFilterRule"
|
||||
>
|
||||
<Filter class="h-3 w-3" />
|
||||
<span v-if="localFilterCount + mongoStructuredFilterCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">
|
||||
{{ localFilterCount + mongoStructuredFilterCount }}
|
||||
<span v-if="localFilterCount + documentStructuredFilterCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">
|
||||
{{ localFilterCount + documentStructuredFilterCount }}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-[360px] max-w-[calc(100vw-24px)] gap-3 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="addMongoFilterRule">
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="addDocumentFilterRule">
|
||||
<Plus class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("grid.filterBuilderAddRule") }}
|
||||
</Button>
|
||||
@@ -845,15 +861,15 @@ function resetTableSearchSplitWidth() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mongoFilterRules.length" class="space-y-2">
|
||||
<template v-for="(rule, index) in mongoFilterRules" :key="rule.id">
|
||||
<div v-if="documentFilterRules.length" class="space-y-2">
|
||||
<template v-for="(rule, index) in documentFilterRules" :key="rule.id">
|
||||
<div v-if="index > 0" class="flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
@click="
|
||||
updateMongoFilterRule(rule.id, {
|
||||
updateDocumentFilterRule(rule.id, {
|
||||
conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND',
|
||||
})
|
||||
"
|
||||
@@ -862,41 +878,41 @@ function resetTableSearchSplitWidth() {
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto] items-center gap-1.5">
|
||||
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateMongoFilterRule(rule.id, { fieldName: String(value) })">
|
||||
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||
<SelectValue :placeholder="t('grid.filterBuilderColumn')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem v-for="fieldName in mongoFilterFieldOptions" :key="fieldName" :value="fieldName">
|
||||
<SelectItem v-for="fieldName in documentFilterFieldOptions" :key="fieldName" :value="fieldName">
|
||||
{{ fieldName }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select :model-value="rule.mode" @update:model-value="(value: any) => updateMongoFilterRule(rule.id, { mode: value as MongoFilterMode })">
|
||||
<Select :model-value="rule.mode" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { mode: value as DocumentFilterMode })">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem v-for="option in mongoFilterModeOptions" :key="option.value" :value="option.value">
|
||||
<SelectItem v-for="option in documentFilterModeOptions" :key="option.value" :value="option.value">
|
||||
{{ t(option.labelKey) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
v-if="mongoFilterModeNeedsValue(rule.mode)"
|
||||
v-if="documentFilterModeNeedsValue(rule.mode)"
|
||||
:model-value="rule.rawValue"
|
||||
class="h-8 min-w-0 text-xs"
|
||||
:placeholder="t('grid.filterBuilderValue')"
|
||||
@update:model-value="(value) => updateMongoFilterRule(rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown.enter.prevent="applyMongoStructuredFilters"
|
||||
@update:model-value="(value) => updateDocumentFilterRule(rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown.enter.prevent="applyDocumentStructuredFilters"
|
||||
/>
|
||||
<div v-else class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border border-dashed px-2 text-xs text-muted-foreground">
|
||||
<span class="truncate">{{ t("grid.filterBuilderNoValue") }}</span>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" :disabled="mongoFilterRules.length === 1" @click="removeMongoFilterRule(rule.id)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" :disabled="documentFilterRules.length === 1" @click="removeDocumentFilterRule(rule.id)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -907,21 +923,21 @@ function resetTableSearchSplitWidth() {
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="clearMongoFilters(clearLocalFilter)">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="clearDocumentFilters(clearLocalFilter)">
|
||||
{{ t("grid.clearFilter") }}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="resetMongoFilterBuilder">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="resetDocumentFilterBuilder">
|
||||
{{ t("grid.resetFilterBuilder") }}
|
||||
</Button>
|
||||
<Button size="sm" class="h-8 px-3 text-xs" @click="applyMongoStructuredFilters">
|
||||
<Button size="sm" class="h-8 px-3 text-xs" @click="applyDocumentStructuredFilters">
|
||||
{{ t("grid.applyFilter") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">find</span>
|
||||
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.filterInputLabel }}</span>
|
||||
<input v-model="filterInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono" placeholder="{}" @keydown.enter="applyFilter" />
|
||||
<button
|
||||
v-if="filterInput.trim()"
|
||||
@@ -937,14 +953,14 @@ function resetTableSearchSplitWidth() {
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex w-2 shrink-0 cursor-col-resize items-center justify-center border-l border-r border-border/80 bg-muted/15 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
||||
aria-label="Resize find and sort"
|
||||
aria-label="Resize document filter and sort"
|
||||
@mousedown="startTableSearchSplitResize"
|
||||
@dblclick.stop="resetTableSearchSplitWidth"
|
||||
>
|
||||
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
|
||||
</button>
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0">
|
||||
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">sort</span>
|
||||
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.sortInputLabel }}</span>
|
||||
<input v-model="sortInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono" placeholder="{}" @keydown.enter="applyFilter" />
|
||||
<button
|
||||
v-if="sortInput.trim()"
|
||||
@@ -1022,9 +1038,7 @@ function resetTableSearchSplitWidth() {
|
||||
{{ t("mongo.selectDocument") }}
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
|
||||
{{ error }}
|
||||
</div>
|
||||
<ErrorBanner v-if="error" :message="error" />
|
||||
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
|
||||
</div>
|
||||
</Pane>
|
||||
File renamed without changes.
@@ -181,6 +181,8 @@ let semanticDiagnosticTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let semanticDiagnosticRunId = 0;
|
||||
let editorIsActive = true;
|
||||
let tableReferenceDropListenerRegistered = false;
|
||||
let imeCompositionActive = false;
|
||||
let pendingImeModelEmit = false;
|
||||
|
||||
function editorThemeAppearance() {
|
||||
return isDark.value ? "dark" : "light";
|
||||
@@ -682,7 +684,7 @@ async function refreshSemanticDiagnostics() {
|
||||
setSemanticDiagnostics([]);
|
||||
return;
|
||||
}
|
||||
if (props.databaseType === "mongodb" || props.databaseType === "elasticsearch") {
|
||||
if (props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "redis") {
|
||||
setSemanticDiagnostics([]);
|
||||
return;
|
||||
}
|
||||
@@ -904,6 +906,7 @@ async function provideElasticsearchCompletions(currentState: import("@codemirror
|
||||
}
|
||||
|
||||
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
|
||||
if (imeCompositionActive || view.value?.compositionStarted || view.value?.composing) return null;
|
||||
if (!props.connectionId) return null;
|
||||
const fullDoc = currentState.doc.toString();
|
||||
if (props.databaseType === "elasticsearch") {
|
||||
@@ -990,6 +993,23 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E
|
||||
}
|
||||
}
|
||||
|
||||
function isEditorComposing(currentView: EditorViewType): boolean {
|
||||
return imeCompositionActive || currentView.compositionStarted || currentView.composing;
|
||||
}
|
||||
|
||||
function flushImeComposition() {
|
||||
const currentView = view.value;
|
||||
if (!currentView || !pendingImeModelEmit) return;
|
||||
pendingImeModelEmit = false;
|
||||
emit("update:modelValue", currentView.state.doc.toString());
|
||||
scheduleSemanticDiagnostics();
|
||||
syncContextMenuState(currentView);
|
||||
emit("selectionChange", selectedSqlFromView(currentView));
|
||||
emit("cursorChange", currentView.state.selection.main.head);
|
||||
latestSelection = readEditorSelection(currentView);
|
||||
if (editorIsActive) emitEditorSelection(latestSelection);
|
||||
}
|
||||
|
||||
function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number) {
|
||||
if (!props.connectionId || props.database == null) return null;
|
||||
const databaseNames = supportsDatabaseQualifierCompletion() && completionContext.suggestTables && !completionContext.insertTable ? connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES) : [];
|
||||
@@ -1547,14 +1567,19 @@ onMounted(async () => {
|
||||
rectangularSelection({ eventFilter: (e: MouseEvent) => e.altKey || e.button === 1 }),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
emit("update:modelValue", update.state.doc.toString());
|
||||
scheduleSemanticDiagnostics();
|
||||
let insertedText = "";
|
||||
update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
|
||||
insertedText += inserted.toString();
|
||||
});
|
||||
if (insertedText.endsWith(".")) {
|
||||
startCompletion(update.view);
|
||||
if (isEditorComposing(update.view)) {
|
||||
pendingImeModelEmit = true;
|
||||
completionEpoch++;
|
||||
} else {
|
||||
emit("update:modelValue", update.state.doc.toString());
|
||||
scheduleSemanticDiagnostics();
|
||||
let insertedText = "";
|
||||
update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
|
||||
insertedText += inserted.toString();
|
||||
});
|
||||
if (insertedText.endsWith(".")) {
|
||||
startCompletion(update.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
@@ -1586,6 +1611,16 @@ onMounted(async () => {
|
||||
if (editorIsActive) emitEditorSelection(latestSelection);
|
||||
return false;
|
||||
},
|
||||
compositionstart() {
|
||||
imeCompositionActive = true;
|
||||
completionEpoch++;
|
||||
return false;
|
||||
},
|
||||
compositionend() {
|
||||
imeCompositionActive = false;
|
||||
window.setTimeout(flushImeComposition, 0);
|
||||
return false;
|
||||
},
|
||||
wheel(event) {
|
||||
if (!event.metaKey && !event.ctrlKey) return false;
|
||||
event.preventDefault();
|
||||
@@ -1736,6 +1771,7 @@ watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (view.value && val !== view.value.state.doc.toString()) {
|
||||
if (isEditorComposing(view.value)) return;
|
||||
view.value.dispatch({
|
||||
changes: { from: 0, to: view.value.state.doc.length, insert: val },
|
||||
});
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
WrapText,
|
||||
Info,
|
||||
Rows3,
|
||||
TriangleAlert,
|
||||
RefreshCcw,
|
||||
RotateCcw,
|
||||
Pencil,
|
||||
@@ -72,6 +71,7 @@ import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/compon
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ImagePreviewDialog from "@/components/grid/ImagePreviewDialog.vue";
|
||||
@@ -181,7 +181,7 @@ const props = defineProps<{
|
||||
cacheKey?: string;
|
||||
onExecuteSql?: (sql: string) => Promise<void>;
|
||||
fullExportResult?: () => Promise<QueryResult | undefined>;
|
||||
customSave?: (changes: { dirtyRows: Map<number, Map<number, CellValue>>; newRows: CellValue[][]; deletedRows: Set<number>; columns: string[]; rows: CellValue[][] }) => Promise<void>;
|
||||
customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler;
|
||||
}>();
|
||||
|
||||
const dataGridTraceId = uuid().slice(0, 8);
|
||||
@@ -1902,7 +1902,7 @@ const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsCont
|
||||
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value);
|
||||
type DataGridTableMeta = NonNullable<typeof props.tableMeta>;
|
||||
const hiveTableTransactional = ref<boolean | undefined>(undefined);
|
||||
const canEditExistingRows = computed(() => !!props.customSave || canEditExistingTableRows(props.databaseType, hiveTableTransactional.value, props.tableMeta?.primaryKeys ?? []));
|
||||
const canEditExistingRows = computed(() => !!props.customSaveHandler || canEditExistingTableRows(props.databaseType, hiveTableTransactional.value, props.tableMeta?.primaryKeys ?? []));
|
||||
watch(
|
||||
() => [props.databaseType, props.connectionId, props.database, props.tableMeta?.schema, props.tableMeta?.tableName],
|
||||
async () => {
|
||||
@@ -2118,7 +2118,7 @@ const editor = useDataGridEditor({
|
||||
sourceColumns: computed(() => props.sourceColumns),
|
||||
canEditExistingRows,
|
||||
onExecuteSql: computed(() => props.onExecuteSql),
|
||||
customSave: computed(() => props.customSave),
|
||||
customSaveHandler: computed(() => props.customSaveHandler),
|
||||
sql: computed(() => props.sql),
|
||||
searchText,
|
||||
whereFilterInput,
|
||||
@@ -2234,7 +2234,7 @@ const saveActionMode = computed(() =>
|
||||
const saveToolbarState = computed(() =>
|
||||
dataGridSaveToolbarState({
|
||||
editable: props.editable,
|
||||
hasSaveTarget: !!props.tableMeta || !!props.customSave,
|
||||
hasSaveTarget: !!props.tableMeta || !!props.customSaveHandler,
|
||||
hasPendingChanges: hasPendingChanges.value,
|
||||
isSaving: isSaving.value,
|
||||
}),
|
||||
@@ -2242,13 +2242,13 @@ const saveToolbarState = computed(() =>
|
||||
const hasSearchBarSlot = computed(() => !!slots["search-bar"]);
|
||||
const showDataGridTopbar = computed(
|
||||
() =>
|
||||
(useTransaction.value && !!props.editable && (!!props.tableMeta || !!props.customSave)) ||
|
||||
(useTransaction.value && !!props.editable && (!!props.tableMeta || !!props.customSaveHandler)) ||
|
||||
hasLocalColumnFilters.value ||
|
||||
canShowWhereSearch.value ||
|
||||
hasSearchBarSlot.value ||
|
||||
showQueryEditReadyBadge.value ||
|
||||
props.context !== "results" ||
|
||||
(!!props.editable && (!!props.tableMeta || !!props.customSave)) ||
|
||||
(!!props.editable && (!!props.tableMeta || !!props.customSaveHandler)) ||
|
||||
transactionActive.value ||
|
||||
saveToolbarState.value.showActions,
|
||||
);
|
||||
@@ -2616,11 +2616,6 @@ const {
|
||||
isRowSelected,
|
||||
} = selection;
|
||||
|
||||
const selectionSummary = computed(() => {
|
||||
if (hasRowSelection.value) return t("grid.selectedRows", { count: selectedRowCount.value });
|
||||
return t("grid.selectedCells", { count: selectedCellCount.value });
|
||||
});
|
||||
|
||||
const multiRowCount = computed(() => {
|
||||
if (hasRowSelection.value) return selectedRowCount.value;
|
||||
const range = selectedRange.value;
|
||||
@@ -3734,9 +3729,11 @@ function canvasEditingCellViewportRect() {
|
||||
function canvasEditingCellIsVisible() {
|
||||
const rect = canvasEditingCellViewportRect();
|
||||
if (!rect) return false;
|
||||
const viewportWidth = canvasEffectiveViewportWidth();
|
||||
const viewportHeight = canvasEffectiveViewportHeight();
|
||||
const clippedLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, rect.left);
|
||||
const clippedRight = Math.min(canvasViewportWidth.value, rect.left + rect.width);
|
||||
return rect.top + rect.height > 0 && rect.top < canvasViewportHeight.value && clippedRight - clippedLeft > 0;
|
||||
const clippedRight = viewportWidth > 0 ? Math.min(viewportWidth, rect.left + rect.width) : rect.left + rect.width;
|
||||
return rect.top + rect.height > 0 && rect.top < viewportHeight && clippedRight - clippedLeft > 0;
|
||||
}
|
||||
|
||||
function commitHiddenCanvasEditBeforeCellInteraction() {
|
||||
@@ -3768,11 +3765,30 @@ const canvasSingleSelectedCell = computed(() => {
|
||||
return { rowIndex: range.startRow, visibleColIdx: range.startCol };
|
||||
});
|
||||
|
||||
function canvasEffectiveViewportWidth(): number {
|
||||
return canvasViewportWidth.value || canvasScrollerElement()?.clientWidth || 0;
|
||||
}
|
||||
|
||||
function canvasEffectiveViewportHeight(): number {
|
||||
return canvasViewportHeight.value || canvasScrollerElement()?.clientHeight || 0;
|
||||
}
|
||||
|
||||
const canvasOverlayStyle = computed(() => {
|
||||
const vw = canvasEffectiveViewportWidth();
|
||||
const vh = canvasEffectiveViewportHeight();
|
||||
return {
|
||||
width: `${vw}px`,
|
||||
height: `${vh}px`,
|
||||
marginTop: `-${vh}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const canvasEditingCellStyle = computed(() => {
|
||||
const cell = canvasEditingCell.value;
|
||||
if (!cell) return {};
|
||||
const viewportWidth = canvasEffectiveViewportWidth();
|
||||
const clippedLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, cell.rect.left);
|
||||
const clippedRight = Math.min(canvasViewportWidth.value, cell.rect.left + cell.rect.width);
|
||||
const clippedRight = viewportWidth > 0 ? Math.min(viewportWidth, cell.rect.left + cell.rect.width) : cell.rect.left + cell.rect.width;
|
||||
return {
|
||||
left: `${clippedLeft}px`,
|
||||
top: `${cell.rect.top}px`,
|
||||
@@ -3789,11 +3805,13 @@ const canvasDetailButtonCell = computed(() => {
|
||||
if (visibleColIdx < 0) return null;
|
||||
const rect = canvasCellViewportRect(target.rowIndex, visibleColIdx);
|
||||
if (!rect) return null;
|
||||
const viewportWidth = canvasEffectiveViewportWidth();
|
||||
const viewportHeight = canvasEffectiveViewportHeight();
|
||||
const visibleLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, rect.left);
|
||||
const visibleRight = Math.min(canvasViewportWidth.value, rect.left + rect.width);
|
||||
const visibleRight = viewportWidth > 0 ? Math.min(viewportWidth, rect.left + rect.width) : rect.left + rect.width;
|
||||
const canQuickDownload = canQuickDownloadCellValue(target.rowIndex, target.col);
|
||||
const minWidth = canQuickDownload ? 46 : 24;
|
||||
if (rect.top < 0 || rect.top > canvasViewportHeight.value - 1 || visibleRight - visibleLeft < minWidth) return null;
|
||||
if (rect.top < 0 || rect.top > viewportHeight - 1 || visibleRight - visibleLeft < minWidth) return null;
|
||||
return { rowIndex: target.rowIndex, visibleColIdx, actualColIdx: target.col, rect, canQuickDownload };
|
||||
});
|
||||
|
||||
@@ -5858,7 +5876,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
"
|
||||
>
|
||||
<div class="data-grid-topbar flex items-stretch relative">
|
||||
<div v-if="useTransaction && editable && (tableMeta || customSave)" class="flex items-center px-2 py-0.5 border-r shrink-0">
|
||||
<div v-if="useTransaction && editable && (tableMeta || customSaveHandler)" class="flex items-center px-2 py-0.5 border-r shrink-0">
|
||||
<Select :model-value="rowStatusFilter" @update:model-value="(value: any) => setRowStatusFilter(String(value))">
|
||||
<SelectTrigger class="h-5 max-w-28 border-0 bg-transparent px-0 py-0 text-xs font-medium text-foreground/70 shadow-none focus-visible:ring-0 data-[state=open]:text-foreground [&_svg]:size-3">
|
||||
<SelectValue :placeholder="t('grid.filterRows')" />
|
||||
@@ -5883,7 +5901,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</template>
|
||||
<template v-if="canShowWhereSearch">
|
||||
<div ref="searchSplitContainerRef" class="flex flex-1 min-w-0">
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0 relative" :class="{ 'border-l': useTransaction && editable && (tableMeta || customSave) }" :style="whereSearchPaneStyle">
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0 relative" :class="{ 'border-l': useTransaction && editable && (tableMeta || customSaveHandler) }" :style="whereSearchPaneStyle">
|
||||
<Popover v-model:open="filterBuilderOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
@@ -6147,7 +6165,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
{{ t("grid.renderModeHint") }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button v-if="editable && (tableMeta || customSave)" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" @click="addRow"> <Plus class="w-3 h-3 mr-1" /> {{ t("grid.addRow") }} </Button>
|
||||
<Button v-if="editable && (tableMeta || customSaveHandler)" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" @click="addRow"> <Plus class="w-3 h-3 mr-1" /> {{ t("grid.addRow") }} </Button>
|
||||
<template v-if="saveToolbarState.showActions">
|
||||
<Tooltip v-if="pendingChangeCount > 0">
|
||||
<TooltipTrigger as-child>
|
||||
@@ -6230,14 +6248,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
<div v-if="isErrorResult" class="flex-1 flex flex-col items-center justify-center gap-2 px-6 text-center text-destructive">
|
||||
<TriangleAlert class="h-8 w-8 text-destructive/50" aria-hidden="true" />
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium">{{ t("grid.queryError") }}</div>
|
||||
<div class="text-xs max-w-lg break-all text-destructive/80">{{ errorMessage }}</div>
|
||||
</div>
|
||||
<slot name="error-actions" :error-message="errorMessage" />
|
||||
</div>
|
||||
<ErrorBanner v-if="isErrorResult" variant="centered" :message="errorMessage">
|
||||
<template #actions>
|
||||
<slot name="error-actions" :error-message="errorMessage" />
|
||||
</template>
|
||||
</ErrorBanner>
|
||||
<div v-else-if="isTransposeMode" class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div class="h-8 flex items-center gap-2 px-3 border-y shrink-0 bg-muted/20">
|
||||
<Rows3 class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
@@ -6708,15 +6723,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
@contextmenu="onCanvasContext"
|
||||
@dblclick="onCanvasDblClick"
|
||||
/>
|
||||
<div
|
||||
ref="canvasOverlayRef"
|
||||
class="canvas-grid-overlay sticky left-0 top-0 z-10 overflow-hidden"
|
||||
:style="{
|
||||
width: `${canvasViewportWidth}px`,
|
||||
height: `${canvasViewportHeight}px`,
|
||||
marginTop: `-${canvasViewportHeight}px`,
|
||||
}"
|
||||
>
|
||||
<div ref="canvasOverlayRef" class="canvas-grid-overlay sticky left-0 top-0 z-10 overflow-hidden" :style="canvasOverlayStyle">
|
||||
<div v-if="canvasEditingCell" class="absolute pointer-events-auto z-20" :class="{ 'tabular-nums': canvasEditingCellIsNumeric }" :style="canvasEditingCellStyle" @mousedown.stop @click.stop>
|
||||
<TemporalCellEditor v-if="temporalEditorKindForColumn(canvasEditingCell.actualColIdx)" v-model="editValue" :kind="temporalEditorKindForColumn(canvasEditingCell.actualColIdx)!" @cancel="cancelEdit" @commit="commitGridEdit" />
|
||||
<EnumCellEditor
|
||||
@@ -7264,11 +7271,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
<span v-if="showTruncationWarning" class="shrink-0 text-amber-500 text-xs">(truncated)</span>
|
||||
<span v-if="!hasData" class="shrink-0">{{ t("grid.rowsAffected", { count: result.affected_rows }) }}</span>
|
||||
<span class="shrink-0">{{ result.execution_time_ms }}ms</span>
|
||||
<span v-if="selectedRowCount > 0 || hasCellSelection" class="min-w-0 truncate text-foreground">
|
||||
{{ selectionSummary }}
|
||||
</span>
|
||||
|
||||
<template v-if="editable && (tableMeta || customSave)">
|
||||
<template v-if="editable && (tableMeta || customSaveHandler)">
|
||||
<span v-if="hasPendingChanges" class="shrink-0 text-foreground">
|
||||
{{ t("grid.pendingChanges", { count: pendingChangeCount }) }}
|
||||
</span>
|
||||
@@ -7753,7 +7757,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
.data-grid-horizontal-scrollbar {
|
||||
position: absolute;
|
||||
inset-inline: calc(var(--row-num-w) + 8px) 10px;
|
||||
bottom: 8px;
|
||||
bottom: 2px;
|
||||
z-index: 30;
|
||||
height: 10px;
|
||||
cursor: pointer;
|
||||
@@ -7767,7 +7771,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
top: 4px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--foreground) 12%, transparent);
|
||||
background: color-mix(in oklch, var(--foreground) 7%, transparent);
|
||||
}
|
||||
|
||||
.data-grid-horizontal-scrollbar__thumb {
|
||||
@@ -7776,7 +7780,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
height: 4px;
|
||||
min-width: 24px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--foreground) 42%, transparent);
|
||||
background: color-mix(in oklch, var(--foreground) 24%, transparent);
|
||||
transition:
|
||||
height 120ms ease,
|
||||
background-color 120ms ease,
|
||||
@@ -7787,7 +7791,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
.data-grid-horizontal-scrollbar--dragging .data-grid-horizontal-scrollbar__thumb {
|
||||
top: 2px;
|
||||
height: 6px;
|
||||
background: color-mix(in oklch, var(--foreground) 62%, transparent);
|
||||
background: color-mix(in oklch, var(--foreground) 42%, transparent);
|
||||
}
|
||||
|
||||
.canvas-grid-surface {
|
||||
|
||||
@@ -30,6 +30,7 @@ const emit = defineEmits<{
|
||||
const open = ref(true);
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null);
|
||||
let closeHandled = false;
|
||||
let isCommitting = false;
|
||||
|
||||
const hasDate = computed(() => props.kind !== "time");
|
||||
const hasTime = computed(() => props.kind !== "date");
|
||||
@@ -136,12 +137,16 @@ function setNow() {
|
||||
}
|
||||
|
||||
function finishCommit() {
|
||||
if (isCommitting) return;
|
||||
closeHandled = true;
|
||||
isCommitting = true;
|
||||
emit("commit");
|
||||
}
|
||||
|
||||
function finishCancel() {
|
||||
if (isCommitting) return;
|
||||
closeHandled = true;
|
||||
isCommitting = true;
|
||||
emit("cancel");
|
||||
}
|
||||
|
||||
@@ -200,7 +205,7 @@ function twoDigit(value: string | number): string {
|
||||
<PopoverContent align="start" side="bottom" class="w-auto gap-1.5 rounded-md p-1.5" @click.stop @keydown.stop="onKeydown" @interact-outside="onPopoverInteractOutside">
|
||||
<div v-if="hasDate" class="grid grid-cols-[4.5rem_4.5rem_4.5rem] gap-1.5">
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="dateParts.year" data-temporal-part="year" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateDateFromInput('year', $event)" />
|
||||
<input :value="dateParts.year" data-temporal-part="year" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @input="updateDateFromInput('year', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('year', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
@@ -212,7 +217,7 @@ function twoDigit(value: string | number): string {
|
||||
</div>
|
||||
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="twoDigit(dateParts.month)" data-temporal-part="month" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateDateFromInput('month', $event)" />
|
||||
<input :value="twoDigit(dateParts.month)" data-temporal-part="month" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @input="updateDateFromInput('month', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('month', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
@@ -224,7 +229,7 @@ function twoDigit(value: string | number): string {
|
||||
</div>
|
||||
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="twoDigit(dateParts.day)" data-temporal-part="day" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateDateFromInput('day', $event)" />
|
||||
<input :value="twoDigit(dateParts.day)" data-temporal-part="day" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @input="updateDateFromInput('day', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('day', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
@@ -238,7 +243,7 @@ function twoDigit(value: string | number): string {
|
||||
|
||||
<div v-if="hasTime" class="grid grid-cols-[3.5rem_0.5rem_3.5rem_0.5rem_3.5rem] items-center gap-1.5">
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="twoDigit(timeParts.hour)" data-temporal-part="hour" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateTimeFromInput('hour', $event)" />
|
||||
<input :value="twoDigit(timeParts.hour)" data-temporal-part="hour" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @input="updateTimeFromInput('hour', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepTime('hour', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
@@ -250,7 +255,7 @@ function twoDigit(value: string | number): string {
|
||||
</div>
|
||||
<span class="text-center text-xs text-muted-foreground">:</span>
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="twoDigit(timeParts.minute)" data-temporal-part="minute" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateTimeFromInput('minute', $event)" />
|
||||
<input :value="twoDigit(timeParts.minute)" data-temporal-part="minute" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @input="updateTimeFromInput('minute', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepTime('minute', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
@@ -262,7 +267,7 @@ function twoDigit(value: string | number): string {
|
||||
</div>
|
||||
<span class="text-center text-xs text-muted-foreground">:</span>
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="twoDigit(timeParts.second)" data-temporal-part="second" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateTimeFromInput('second', $event)" />
|
||||
<input :value="twoDigit(timeParts.second)" data-temporal-part="second" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @input="updateTimeFromInput('second', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepTime('second', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { computed, ref, watch, nextTick } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Check, Lock, Copy } from "@lucide/vue";
|
||||
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Check, Lock, Copy, AlertTriangle } from "@lucide/vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
@@ -22,6 +24,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
"toggle-driver-store": [];
|
||||
"close-driver-store": [];
|
||||
"save-tab": [tabId: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -113,6 +116,19 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
|
||||
];
|
||||
}
|
||||
|
||||
function handleSaveAndClose() {
|
||||
const id = queryStore.saveAndClosePendingTab();
|
||||
if (id) emit("save-tab", id);
|
||||
}
|
||||
|
||||
function handleDiscardAndClose() {
|
||||
queryStore.forceClosePendingTab();
|
||||
}
|
||||
|
||||
function handleCancelClose() {
|
||||
queryStore.cancelClosePendingTab();
|
||||
}
|
||||
|
||||
const tabsContainerRef = ref<HTMLElement | null>(null);
|
||||
const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(tabsContainerRef);
|
||||
const tabScrollBehavior = ref<ScrollBehavior>("smooth");
|
||||
@@ -267,7 +283,7 @@ function activateTab(tabId: string) {
|
||||
|
||||
<template>
|
||||
<div v-if="queryStore.tabs.length > 0 || showDriverStore" class="relative flex border-b shrink-0" :class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-9 items-stretch bg-muted' : 'h-10 items-center bg-background px-2'">
|
||||
<div class="relative h-full min-w-0 flex-1">
|
||||
<div class="app-tab-strip relative h-full min-w-0 flex-1">
|
||||
<div v-if="showTabOverflowControls" class="app-tab-scrollbar" :class="{ 'app-tab-scrollbar--dragging': isScrollbarDragging }" @pointerdown="startScrollbarDrag">
|
||||
<div class="app-tab-scrollbar__thumb" :style="tabScrollbarThumbStyle" />
|
||||
</div>
|
||||
@@ -387,6 +403,30 @@ function activateTab(tabId: string) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
:open="queryStore.showCloseConfirm"
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open) queryStore.cancelClosePendingTab();
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<AlertTriangle class="h-5 w-5 text-amber-500" />
|
||||
{{ t("editor.unsavedChangesTitle") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t("editor.unsavedChangesMessage") }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="handleCancelClose">{{ t("common.cancel") }}</Button>
|
||||
<Button variant="secondary" @click="handleDiscardAndClose">{{ t("editor.discardChanges") }}</Button>
|
||||
<Button @click="handleSaveAndClose">{{ t("savedSql.save") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -401,7 +441,17 @@ function activateTab(tabId: string) {
|
||||
z-index: 20;
|
||||
height: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.app-tab-strip:hover .app-tab-scrollbar,
|
||||
.app-tab-strip:focus-within .app-tab-scrollbar,
|
||||
.app-tab-scrollbar--dragging {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-tab-scrollbar::before {
|
||||
|
||||
@@ -33,7 +33,7 @@ function preloadDataGridComponent() {
|
||||
const DataGrid = defineAsyncComponent(loadDataGridComponent);
|
||||
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
|
||||
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
|
||||
const MongoDocBrowser = defineAsyncComponent(() => import("@/components/mongo/MongoDocBrowser.vue"));
|
||||
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
|
||||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||||
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
|
||||
const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue"));
|
||||
@@ -823,7 +823,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
||||
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
|
||||
<template v-else-if="activeTab.mode === 'mongo'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MongoDocBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" />
|
||||
<DocumentBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watchEffect } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers, X } from "@lucide/vue";
|
||||
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers, X, Shield } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
@@ -23,6 +23,7 @@ const props = defineProps<{
|
||||
activeConnection?: ConnectionConfig;
|
||||
executableSql: string;
|
||||
explainMode?: string;
|
||||
blockDangerousRedisCommands?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -38,6 +39,7 @@ const emit = defineEmits<{
|
||||
changeSchema: [schema: string | undefined];
|
||||
setDefaultDatabase: [];
|
||||
clearDefaultDatabase: [];
|
||||
"update:blockDangerousRedisCommands": [value: boolean];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -54,6 +56,10 @@ const connectionOptionIds = computed(() => connectionStore.connections.map((conn
|
||||
const activeDatabaseValue = computed(() => props.activeTab.database || "");
|
||||
const activeConnectionValue = computed(() => props.activeConnection?.id || "");
|
||||
const activeSchemaValue = computed(() => props.activeTab.schema || "");
|
||||
const supportsExplain = computed(() => {
|
||||
const dbType = props.activeConnection?.db_type;
|
||||
return dbType !== "redis" && dbType !== "mongodb" && dbType !== "elasticsearch" && dbType !== "etcd";
|
||||
});
|
||||
const isSingleDb = computed(() => isSingleDatabase(props.activeConnection?.db_type));
|
||||
const hasDefaultDatabaseOption = computed(() => activeDatabaseOptions.value.includes(""));
|
||||
const schemaDatabaseKey = computed(() => props.activeTab.database || (isSingleDb.value ? "_" : ""));
|
||||
@@ -119,7 +125,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ activeTab.isExecuting ? t("toolbar.stopQuery") : t("toolbar.executeShortcut") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<Tooltip v-if="supportsExplain">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:variant="activeTab.isExplaining ? 'destructive' : 'ghost'"
|
||||
@@ -155,6 +161,20 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("toolbar.formatSql") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="activeConnection?.db_type === 'redis'">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="blockDangerousRedisCommands !== false ? 'text-orange-600 bg-orange-100 dark:text-orange-300 dark:bg-orange-900/30' : 'text-muted-foreground/50'"
|
||||
@click="emit('update:blockDangerousRedisCommands', blockDangerousRedisCommands === false)"
|
||||
>
|
||||
<Shield class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("toolbar.blockDangerousRedisCommands") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-blue-600 hover:bg-blue-500/10 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200" :disabled="!activeTab.sql.trim()" @click="emit('saveSql')">
|
||||
|
||||
@@ -147,19 +147,25 @@ async function exportFolderContents(folder?: SavedSqlFolder) {
|
||||
const rootDir = await join(targetDir, rootDirName);
|
||||
await mkdir(rootDir, { recursive: true });
|
||||
|
||||
if (folder) {
|
||||
for (const file of savedSqlStore.filesInFolder(folder.id)) {
|
||||
const filePath = await join(rootDir, sanitizeFileSystemSegment(ensureSqlExtension(file.name)));
|
||||
const writeFolder = async (libraryFolder: SavedSqlFolder, dir: string): Promise<void> => {
|
||||
for (const child of savedSqlStore.allFolders.filter((item) => item.parentFolderId === libraryFolder.id)) {
|
||||
const childDir = await join(dir, sanitizeFileSystemSegment(child.name));
|
||||
await mkdir(childDir, { recursive: true });
|
||||
await writeFolder(child, childDir);
|
||||
}
|
||||
for (const file of savedSqlStore.filesInFolder(libraryFolder.id)) {
|
||||
const filePath = await join(dir, sanitizeFileSystemSegment(ensureSqlExtension(file.name)));
|
||||
await writeTextFile(filePath, file.sql);
|
||||
}
|
||||
};
|
||||
|
||||
if (folder) {
|
||||
await writeFolder(folder, rootDir);
|
||||
} else {
|
||||
for (const libraryFolder of savedSqlStore.allFolders.filter((item) => isConnectionVisible(item.connectionId))) {
|
||||
for (const libraryFolder of savedSqlStore.allFolders.filter((item) => isConnectionVisible(item.connectionId) && !item.parentFolderId)) {
|
||||
const folderDir = await join(rootDir, sanitizeFileSystemSegment(libraryFolder.name));
|
||||
await mkdir(folderDir, { recursive: true });
|
||||
for (const file of savedSqlStore.filesInFolder(libraryFolder.id)) {
|
||||
const filePath = await join(folderDir, sanitizeFileSystemSegment(ensureSqlExtension(file.name)));
|
||||
await writeTextFile(filePath, file.sql);
|
||||
}
|
||||
await writeFolder(libraryFolder, folderDir);
|
||||
}
|
||||
|
||||
const unfiled = savedSqlStore.filesWithoutFolder().filter((file) => !orphanedIds.value.has(file.id));
|
||||
@@ -310,6 +316,20 @@ function folderMatchesQuery(folder: SavedSqlFolder) {
|
||||
return savedSqlStore.filesInFolder(folder.id).some((file) => !orphanedIds.value.has(file.id) && fileMatchesQuery(file));
|
||||
}
|
||||
|
||||
function childFolders(parentFolderId?: string) {
|
||||
return savedSqlStore.allFolders.filter((folder) => isConnectionVisible(folder.connectionId) && (folder.parentFolderId || "") === (parentFolderId || ""));
|
||||
}
|
||||
|
||||
function descendantFolders(parentFolderId: string): SavedSqlFolder[] {
|
||||
const direct = childFolders(parentFolderId);
|
||||
return direct.flatMap((folder) => [folder, ...descendantFolders(folder.id)]);
|
||||
}
|
||||
|
||||
function folderBranchMatchesQuery(folder: SavedSqlFolder) {
|
||||
if (folderMatchesQuery(folder)) return true;
|
||||
return descendantFolders(folder.id).some((child) => folderMatchesQuery(child));
|
||||
}
|
||||
|
||||
function filesInFolder(folderId: string) {
|
||||
const folder = savedSqlStore.allFolders.find((item) => item.id === folderId);
|
||||
const includeAllFilesForMatchedFolder = !!folder && !!searchQuery.value && folder.name.toLowerCase().includes(searchQuery.value);
|
||||
@@ -319,7 +339,26 @@ function filesInFolder(folderId: string) {
|
||||
.filter((file) => includeAllFilesForMatchedFolder || fileMatchesQuery(file));
|
||||
}
|
||||
|
||||
const visibleFolders = computed(() => savedSqlStore.allFolders.filter((folder) => isConnectionVisible(folder.connectionId) && folderMatchesQuery(folder)));
|
||||
type SqlLibraryRow = { type: "folder"; folder: SavedSqlFolder; depth: number } | { type: "file"; file: SavedSqlFile; depth: number };
|
||||
|
||||
const visibleFolderRows = computed<SqlLibraryRow[]>(() => {
|
||||
const rows: SqlLibraryRow[] = [];
|
||||
const appendFolder = (folder: SavedSqlFolder, depth: number) => {
|
||||
if (!folderBranchMatchesQuery(folder)) return;
|
||||
rows.push({ type: "folder", folder, depth });
|
||||
if (!isFolderExpanded(folder.id)) return;
|
||||
for (const child of childFolders(folder.id)) {
|
||||
appendFolder(child, depth + 1);
|
||||
}
|
||||
for (const file of filesInFolder(folder.id)) {
|
||||
rows.push({ type: "file", file, depth: depth + 1 });
|
||||
}
|
||||
};
|
||||
for (const folder of childFolders()) {
|
||||
appendFolder(folder, 0);
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const visibleFiles = computed(() =>
|
||||
savedSqlStore
|
||||
@@ -328,7 +367,7 @@ const visibleFiles = computed(() =>
|
||||
.filter((file) => fileMatchesQuery(file)),
|
||||
);
|
||||
|
||||
const hasAnyVisibleItem = computed(() => visibleFolders.value.length > 0 || visibleFiles.value.length > 0);
|
||||
const hasAnyVisibleItem = computed(() => visibleFolderRows.value.length > 0 || visibleFiles.value.length > 0);
|
||||
|
||||
const collapsedFolders = ref<Set<string>>(new Set());
|
||||
|
||||
@@ -344,31 +383,15 @@ function isFolderExpanded(folderId: string) {
|
||||
return !collapsedFolders.value.has(folderId);
|
||||
}
|
||||
|
||||
const showNewFolderInput = ref(false);
|
||||
const newFolderName = ref("");
|
||||
const newFolderInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function openNewFolderInput() {
|
||||
newFolderName.value = "";
|
||||
showNewFolderInput.value = true;
|
||||
nextTick(() => newFolderInputRef.value?.focus());
|
||||
}
|
||||
|
||||
async function confirmNewFolder() {
|
||||
const name = newFolderName.value.trim();
|
||||
if (!name) {
|
||||
cancelNewFolder();
|
||||
return;
|
||||
}
|
||||
showNewFolderInput.value = false;
|
||||
const connectionId = connectionStore.connections[0]?.id;
|
||||
async function openNewFolderInput(parentFolderId?: string) {
|
||||
const parent = parentFolderId ? savedSqlStore.allFolders.find((folder) => folder.id === parentFolderId) : undefined;
|
||||
const connectionId = parent?.connectionId || connectionStore.connections[0]?.id;
|
||||
if (!connectionId) return;
|
||||
await savedSqlStore.createFolder(connectionId, name);
|
||||
}
|
||||
|
||||
function cancelNewFolder() {
|
||||
showNewFolderInput.value = false;
|
||||
newFolderName.value = "";
|
||||
if (parent?.id) {
|
||||
collapsedFolders.value = new Set([...collapsedFolders.value].filter((id) => id !== parent.id));
|
||||
}
|
||||
const folder = await savedSqlStore.createFolder(connectionId, t("savedSql.newFolderDefault"), parent?.id);
|
||||
startRenameFolder(folder);
|
||||
}
|
||||
|
||||
const renamingTarget = ref<{ type: "folder" | "file"; id: string } | null>(null);
|
||||
@@ -479,6 +502,7 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: t("savedSql.newFolder"), action: () => openNewFolderInput(target.id), icon: FolderPlus },
|
||||
{ label: t("sqlLibrary.importIntoFolder"), action: () => importDirectoryIntoLibrary(target), icon: Upload },
|
||||
{ label: t("sqlLibrary.exportFolder"), action: () => exportFolderContents(target), icon: Download },
|
||||
{ label: "", separator: true },
|
||||
@@ -619,6 +643,11 @@ async function performDrop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draggedType === "folder" && targetType === "folder" && dropPosition === "inside") {
|
||||
await savedSqlStore.moveFolderToFolder(draggedId, targetId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (draggedType !== "file") return;
|
||||
|
||||
if (targetType === "folder") {
|
||||
@@ -677,7 +706,9 @@ function updateDropTarget(event: MouseEvent, targetId: string, targetType: DragI
|
||||
let nextPosition: DropPosition | null = null;
|
||||
if (dragState.draggedType === "folder" && targetType === "folder") {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
nextPosition = event.clientY - rect.top < rect.height / 2 ? "before" : "after";
|
||||
const y = event.clientY - rect.top;
|
||||
const third = rect.height / 3;
|
||||
nextPosition = y > third && y < rect.height - third ? "inside" : y < rect.height / 2 ? "before" : "after";
|
||||
} else if (dragState.draggedType === "file" && targetType === "file") {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
nextPosition = event.clientY - rect.top < rect.height / 2 ? "before" : "after";
|
||||
@@ -744,19 +775,6 @@ function showDropInside(targetId: string) {
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
<div v-if="showNewFolderInput" class="flex items-center gap-1 px-2 py-1.5">
|
||||
<FolderOpen class="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<input
|
||||
ref="newFolderInputRef"
|
||||
v-model="newFolderName"
|
||||
class="h-6 min-w-0 flex-1 rounded border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
:placeholder="t('savedSql.newFolderDefault')"
|
||||
@keydown.enter.prevent="confirmNewFolder"
|
||||
@keydown.escape.prevent="cancelNewFolder"
|
||||
@blur="confirmNewFolder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomContextMenu :items="contextMenuItems" @close="clearContextTarget">
|
||||
<template #default="{ onContextMenu }">
|
||||
<div
|
||||
@@ -766,23 +784,25 @@ function showDropInside(targetId: string) {
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
<div v-for="folder in visibleFolders" :key="folder.id" class="mb-0.5">
|
||||
<div v-for="row in visibleFolderRows" :key="row.type === 'folder' ? row.folder.id : row.file.id" class="mb-0.5">
|
||||
<div
|
||||
class="relative flex items-center gap-1 rounded px-2 py-1.5 text-xs cursor-pointer transition-colors group"
|
||||
:class="[showDropInside(folder.id) ? 'ring-1 ring-primary/50 bg-primary/5' : 'hover:bg-accent', isDraggingItem(folder.id) ? 'opacity-50' : '']"
|
||||
@mousedown="handleDragMouseDown($event, folder.id, 'folder')"
|
||||
@mousemove="updateDropTarget($event, folder.id, 'folder')"
|
||||
@mouseleave="clearDropTarget(folder.id)"
|
||||
@click="toggleFolder(folder.id)"
|
||||
v-if="row.type === 'folder'"
|
||||
class="relative flex items-center gap-1 rounded py-1.5 pr-2 text-xs cursor-pointer transition-colors group"
|
||||
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
|
||||
:class="[showDropInside(row.folder.id) ? 'ring-1 ring-primary/50 bg-primary/5' : 'hover:bg-accent', isDraggingItem(row.folder.id) ? 'opacity-50' : '']"
|
||||
@mousedown="handleDragMouseDown($event, row.folder.id, 'folder')"
|
||||
@mousemove="updateDropTarget($event, row.folder.id, 'folder')"
|
||||
@mouseleave="clearDropTarget(row.folder.id)"
|
||||
@click="toggleFolder(row.folder.id)"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = folder;
|
||||
contextTarget = row.folder;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
<div v-if="showDropBefore(folder.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
|
||||
<div v-if="showDropAfter(folder.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
|
||||
<component :is="isFolderExpanded(folder.id) ? FolderOpen : FolderClosed" class="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<template v-if="renamingTarget?.type === 'folder' && renamingTarget.id === folder.id">
|
||||
<div v-if="showDropBefore(row.folder.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
|
||||
<div v-if="showDropAfter(row.folder.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
|
||||
<component :is="isFolderExpanded(row.folder.id) ? FolderOpen : FolderClosed" class="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<template v-if="renamingTarget?.type === 'folder' && renamingTarget.id === row.folder.id">
|
||||
<input
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameValue"
|
||||
@@ -795,48 +815,42 @@ function showDropInside(targetId: string) {
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">
|
||||
{{ folder.name }}
|
||||
<span class="ml-1 text-muted-foreground">({{ filesInFolder(folder.id).length }})</span>
|
||||
{{ row.folder.name }}
|
||||
<span class="ml-1 text-muted-foreground">({{ filesInFolder(row.folder.id).length }})</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isFolderExpanded(folder.id)" class="ml-4">
|
||||
<div
|
||||
v-for="file in filesInFolder(folder.id)"
|
||||
:key="file.id"
|
||||
class="relative flex items-center gap-1 rounded px-2 py-1.5 text-xs cursor-pointer transition-colors group"
|
||||
:class="[isDraggingItem(file.id) ? 'opacity-50' : 'hover:bg-accent']"
|
||||
@mousedown="handleDragMouseDown($event, file.id, 'file')"
|
||||
@mousemove="updateDropTarget($event, file.id, 'file')"
|
||||
@mouseleave="clearDropTarget(file.id)"
|
||||
@click="openFile(file)"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = file;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
<div v-if="showDropBefore(file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
|
||||
<div v-if="showDropAfter(file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
|
||||
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<template v-if="renamingTarget?.type === 'file' && renamingTarget.id === file.id">
|
||||
<input
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameValue"
|
||||
data-no-drag="true"
|
||||
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-xs outline-none"
|
||||
@keydown.enter.prevent="confirmRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="confirmRename"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">{{ file.name }}</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground"> [{{ getConnectionLabel(file.connectionId) }}] </span>
|
||||
</div>
|
||||
|
||||
<div v-if="filesInFolder(folder.id).length === 0" class="px-2 py-1 text-xs text-muted-foreground">
|
||||
{{ t("sqlLibrary.emptyFolder") }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="relative flex items-center gap-1 rounded py-1.5 pr-2 text-xs cursor-pointer transition-colors group"
|
||||
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
|
||||
:class="[isDraggingItem(row.file.id) ? 'opacity-50' : 'hover:bg-accent']"
|
||||
@mousedown="handleDragMouseDown($event, row.file.id, 'file')"
|
||||
@mousemove="updateDropTarget($event, row.file.id, 'file')"
|
||||
@mouseleave="clearDropTarget(row.file.id)"
|
||||
@click="openFile(row.file)"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = row.file;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
<div v-if="showDropBefore(row.file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
|
||||
<div v-if="showDropAfter(row.file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
|
||||
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<template v-if="renamingTarget?.type === 'file' && renamingTarget.id === row.file.id">
|
||||
<input
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameValue"
|
||||
data-no-drag="true"
|
||||
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-xs outline-none"
|
||||
@keydown.enter.prevent="confirmRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="confirmRename"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">{{ row.file.name }}</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground"> [{{ getConnectionLabel(row.file.connectionId) }}] </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -883,7 +897,7 @@ function showDropInside(targetId: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasAnyVisibleItem && !showNewFolderInput" class="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<div v-if="!hasAnyVisibleItem" class="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Library class="h-8 w-8 opacity-30" />
|
||||
<p class="text-xs">{{ t("sqlLibrary.empty") }}</p>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
|
||||
import { canTreeNodeShowExpander, treeItemPaddingLeft, usesFullWidthTreeLabel } from "@/lib/sidebarTreeItemLayout";
|
||||
import { buildTableSelectSql } from "@/lib/tableSelectSql";
|
||||
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/queryEditorTableDrop";
|
||||
import { editablePrimaryKeys } from "@/lib/tableEditing";
|
||||
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
|
||||
import { supportsDatabaseCreation, supportsDatabaseSearch, supportsFieldLineage, supportsObjectBrowserTreeNode, supportsSchemaDiagram, supportsSqlFileExecution, supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, usesTreeSchemaMode } from "@/lib/databaseCapabilities";
|
||||
import { copyNameForTreeNode, objectSourceKindForTreeNode, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick";
|
||||
import { formatSqlInsert } from "@/lib/exportFormats";
|
||||
@@ -700,56 +700,58 @@ async function openData() {
|
||||
const querySchema = connectionObjectTreeQuerySchema(config, node.database, tableSchema);
|
||||
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
||||
const limit = settingsStore.editorSettings.pageSize;
|
||||
let columns: ColumnInfo[] = [];
|
||||
let primaryKeys: string[] = [];
|
||||
try {
|
||||
console.info("[DBX][openData:get-columns:start]", {
|
||||
traceId,
|
||||
database: node.database,
|
||||
schema: querySchema,
|
||||
table: node.label,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
|
||||
primaryKeys = editablePrimaryKeys(effectiveDbType, columns);
|
||||
console.info("[DBX][openData:get-columns:done]", {
|
||||
traceId,
|
||||
columnCount: columns.length,
|
||||
primaryKeys,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: tableSchema,
|
||||
tableName: node.label,
|
||||
columns,
|
||||
primaryKeys,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[DBX][openData:get-columns:error]", { traceId, elapsed: elapsed(), error });
|
||||
}
|
||||
|
||||
const includeRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys);
|
||||
const fallbackOrderColumns = effectiveDbType === "sqlserver" && !primaryKeys.length ? columns.slice(0, 1).map((column) => column.name) : undefined;
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: effectiveDbType,
|
||||
schema: tableSchema,
|
||||
tableName: node.label,
|
||||
columns: [],
|
||||
primaryKeys: [],
|
||||
columns: columns.map((column) => column.name),
|
||||
primaryKeys,
|
||||
fallbackOrderColumns,
|
||||
limit,
|
||||
includeRowId: false,
|
||||
includeRowId,
|
||||
});
|
||||
console.info("[DBX][openData:sql-built]", {
|
||||
traceId,
|
||||
primaryKeys: [],
|
||||
includeRowId: false,
|
||||
primaryKeys,
|
||||
includeRowId,
|
||||
sql,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
queryStore.updateSql(tabId, sql);
|
||||
|
||||
const loadTableMeta = async () => {
|
||||
try {
|
||||
console.info("[DBX][openData:get-columns:start]", {
|
||||
traceId,
|
||||
database: node.database,
|
||||
schema: querySchema,
|
||||
table: node.label,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
|
||||
console.info("[DBX][openData:get-columns:done]", {
|
||||
traceId,
|
||||
columnCount: columns.length,
|
||||
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const pks = editablePrimaryKeys(effectiveDbType, columns);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: tableSchema,
|
||||
tableName: node.label,
|
||||
columns,
|
||||
primaryKeys: pks,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[DBX][openData:get-columns:error]", { traceId, elapsed: elapsed(), error });
|
||||
}
|
||||
};
|
||||
|
||||
console.info("[DBX][openData:execute:start]", { traceId, tabId, elapsed: elapsed() });
|
||||
await queryStore.executeTabSql(tabId, sql);
|
||||
console.info("[DBX][openData:execute:done]", { traceId, tabId, elapsed: elapsed() });
|
||||
void loadTableMeta();
|
||||
} catch (e: any) {
|
||||
console.error("[DBX][openData:error]", { traceId, elapsed: elapsed(), error: e });
|
||||
queryStore.setErrorResult(tabId, e);
|
||||
@@ -2358,6 +2360,11 @@ function newConnectionInGroup() {
|
||||
connectionStore.startCreatingConnectionInGroup(props.node.id);
|
||||
}
|
||||
|
||||
function newSubgroup() {
|
||||
const groupId = connectionStore.createConnectionGroup(t("connectionGroup.newGroupDefault"), props.node.id);
|
||||
connectionStore.selectedTreeNodeId = groupId;
|
||||
}
|
||||
|
||||
function confirmDeleteGroup() {
|
||||
connectionStore.deleteConnectionGroup(props.node.id);
|
||||
showDeleteGroupConfirm.value = false;
|
||||
@@ -2393,12 +2400,18 @@ const availableGroups = computed(() => connectionStore.sidebarLayout.groups);
|
||||
|
||||
const currentGroupId = computed(() => {
|
||||
if (props.node.type !== "connection" || !props.node.connectionId) return null;
|
||||
for (const entry of connectionStore.sidebarLayout.order) {
|
||||
if (entry.type === "group" && entry.connectionIds.includes(props.node.connectionId)) {
|
||||
return entry.id;
|
||||
const find = (entries: typeof connectionStore.sidebarLayout.order): string | null => {
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "group") continue;
|
||||
if ((entry.children ?? entry.connectionIds?.map((id) => ({ type: "connection" as const, id })) ?? []).some((child) => child.type === "connection" && child.id === props.node.connectionId)) {
|
||||
return entry.id;
|
||||
}
|
||||
const found = find(entry.children ?? []);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
return find(connectionStore.sidebarLayout.order);
|
||||
});
|
||||
|
||||
// --- Drag and Drop ---
|
||||
@@ -2697,7 +2710,10 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
|
||||
// 3. Connection Group
|
||||
if (node.type === "connection-group") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("toolbar.newConnection"), action: newConnectionInGroup, icon: Plus });
|
||||
items.push({ label: t("connectionGroup.newGroup"), action: newSubgroup, icon: FolderPlus });
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({
|
||||
label: t("connectionGroup.renameGroup"),
|
||||
@@ -2718,6 +2734,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
|
||||
// 4. Database / Schema
|
||||
if (node.type === "database" || node.type === "schema") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
items.push({ label: "", separator: true });
|
||||
if (canOpenObjectBrowser.value) {
|
||||
items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties });
|
||||
}
|
||||
|
||||
@@ -101,10 +101,16 @@ function toggleDatabase(database: string) {
|
||||
selectedNames.value = next;
|
||||
}
|
||||
|
||||
const isSearching = computed(() => searchText.value.trim().length > 0);
|
||||
|
||||
function selectAll() {
|
||||
selectedNames.value = new Set(listedDatabaseNames.value);
|
||||
}
|
||||
|
||||
function selectFiltered() {
|
||||
selectedNames.value = new Set(filteredDatabaseNames.value);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedNames.value = new Set();
|
||||
}
|
||||
@@ -142,6 +148,9 @@ async function saveSelection() {
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="selectAll">
|
||||
{{ t("visibleDatabases.selectAll") }}
|
||||
</button>
|
||||
<button v-if="isSearching" class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="selectFiltered">
|
||||
{{ t("visibleDatabases.selectFiltered") }}
|
||||
</button>
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="clearSelection">
|
||||
{{ t("visibleDatabases.clear") }}
|
||||
</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { type EditableStructureColumn, type EditableStructureIndex } from "@/lib
|
||||
import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
|
||||
import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
|
||||
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import { buildStructureTargetLabel, combineDataTypeForDatabase, createColumnDrafts, createIndexDrafts, getDataTypeOptions, getDefaultLengthForType, splitDataType, toColumnNames } from "@/lib/tableStructureEditorState";
|
||||
import { buildStructureTargetLabel, combineDataTypeForDatabase, createColumnDrafts, createIndexDrafts, generateIndexName, generateUniqueIndexName, getDataTypeOptions, getDefaultLengthForType, splitDataType, toColumnNames } from "@/lib/tableStructureEditorState";
|
||||
import type { ForeignKeyInfo, TriggerInfo } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
@@ -482,6 +482,7 @@ function addIndex() {
|
||||
id: `new:${uuid()}`,
|
||||
name: "",
|
||||
columns: [],
|
||||
nameEdited: false,
|
||||
isUnique: false,
|
||||
isPrimary: false,
|
||||
filter: "",
|
||||
@@ -500,6 +501,32 @@ function addIndex() {
|
||||
});
|
||||
}
|
||||
|
||||
function structureIndexTableName(): string {
|
||||
return (isCreateMode.value ? newTableName.value : props.tableName).trim();
|
||||
}
|
||||
|
||||
function existingIndexNamesForDraft(index: EditableStructureIndex): string[] {
|
||||
return indexes.value.filter((item) => item.id !== index.id && !item.markedForDrop).map((item) => item.name);
|
||||
}
|
||||
|
||||
function generatedIndexNameForDraft(index: EditableStructureIndex, columnsForName = index.columns): string {
|
||||
return generateUniqueIndexName(structureIndexTableName(), columnsForName, existingIndexNamesForDraft(index));
|
||||
}
|
||||
|
||||
function refreshAutoIndexName(index: EditableStructureIndex, previousColumns = index.columns) {
|
||||
if (index.original || index.nameEdited) return;
|
||||
const previousName = generateIndexName(structureIndexTableName(), previousColumns);
|
||||
const previousUniqueName = generateUniqueIndexName(structureIndexTableName(), previousColumns, existingIndexNamesForDraft(index));
|
||||
const currentName = index.name.trim();
|
||||
if (currentName && currentName !== previousName && currentName !== previousUniqueName) return;
|
||||
index.name = generatedIndexNameForDraft(index);
|
||||
}
|
||||
|
||||
function onIndexNameInput(index: EditableStructureIndex, value: string | number) {
|
||||
index.name = String(value ?? "");
|
||||
index.nameEdited = true;
|
||||
}
|
||||
|
||||
const availableColumnNames = computed(() =>
|
||||
columns.value
|
||||
.filter((c) => !c.markedForDrop)
|
||||
@@ -515,9 +542,11 @@ const filteredColumnNames = computed(() => {
|
||||
});
|
||||
|
||||
function toggleIndexColumn(index: EditableStructureIndex, col: string) {
|
||||
const previousColumns = [...index.columns];
|
||||
const i = index.columns.indexOf(col);
|
||||
if (i >= 0) index.columns.splice(i, 1);
|
||||
else index.columns.push(col);
|
||||
refreshAutoIndexName(index, previousColumns);
|
||||
}
|
||||
|
||||
function toggleIncludedColumn(index: EditableStructureIndex, col: string) {
|
||||
@@ -699,6 +728,12 @@ watch(
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
watch([() => props.tableName, newTableName], () => {
|
||||
for (const index of indexes.value) {
|
||||
refreshAutoIndexName(index);
|
||||
}
|
||||
});
|
||||
|
||||
watch(refreshVersion, (version, previous) => {
|
||||
if (version === previous || !version || isCreateMode.value) return;
|
||||
void loadStructure(true);
|
||||
@@ -1019,7 +1054,7 @@ watch(activeTab, (tab) => {
|
||||
<tbody>
|
||||
<tr v-for="index in indexes" :key="index.id" :class="index.markedForDrop ? 'bg-destructive/5 opacity-60' : ''" :data-new-index-row="!index.original ? 'true' : undefined">
|
||||
<td :class="structureCellClass">
|
||||
<Input v-model="index.name" :class="structureControlClass" :disabled="!canEditIndexDraft(index)" data-index-name-input />
|
||||
<Input :model-value="index.name" :class="structureControlClass" :disabled="!canEditIndexDraft(index)" data-index-name-input @update:model-value="(value: string | number) => onIndexNameInput(index, value)" />
|
||||
</td>
|
||||
<td :class="[structureCellClass, 'overflow-hidden']">
|
||||
<DropdownMenu v-if="canEditIndexDraft(index)">
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Copy, TriangleAlert } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
message: string;
|
||||
variant?: "banner" | "centered";
|
||||
title?: string;
|
||||
}>(),
|
||||
{
|
||||
variant: "banner",
|
||||
},
|
||||
);
|
||||
|
||||
const displayTitle = computed(() => props.title ?? t("grid.queryError"));
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await copyToClipboard(props.message);
|
||||
toast(t("grid.copied"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- banner: 紧凑内联横幅 -->
|
||||
<div v-if="variant === 'banner'" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
|
||||
<span class="flex-1 min-w-0 break-all">{{ message }}</span>
|
||||
<Button variant="ghost" size="icon-sm" class="h-5 w-5 shrink-0 text-destructive/70 hover:text-destructive" :aria-label="t('grid.copy')" @click.stop="copy">
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- centered: 居中占满 -->
|
||||
<div v-else class="flex-1 flex flex-col items-center justify-center gap-2 px-6 text-center text-destructive">
|
||||
<TriangleAlert class="h-8 w-8 text-destructive/50" aria-hidden="true" />
|
||||
<div class="space-y-1 select-text" @mousedown.stop @click.stop>
|
||||
<div class="text-sm font-medium">{{ displayTitle }}</div>
|
||||
<div class="text-xs max-w-lg break-all cursor-text text-destructive/80 select-text">{{ message }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click.stop="copy">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{{ t("grid.copy") }}
|
||||
</Button>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user