mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 16:21:39 +00:00
fix: address lineage review feedback
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -161,14 +162,7 @@ async function executeSql() {
|
||||
executing.value = true;
|
||||
try {
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
const statements = syncSql.value
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !s.startsWith("--"));
|
||||
|
||||
for (const sql of statements) {
|
||||
await api.executeQuery(targetConnectionId.value, targetDatabase.value, sql);
|
||||
}
|
||||
await api.executeScript(targetConnectionId.value, targetDatabase.value, syncSql.value);
|
||||
toast(t("diff.syncSuccess"), 2000);
|
||||
open.value = false;
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { shouldClearHistory, shouldDeleteHistoryEntry } from "@/lib/historyActions";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useHistoryStore();
|
||||
@@ -34,6 +35,18 @@ function copySql(sql: string) {
|
||||
navigator.clipboard.writeText(sql);
|
||||
}
|
||||
|
||||
function confirmDeleteEntry(id: string) {
|
||||
if (shouldDeleteHistoryEntry(() => window.confirm(t("history.confirmDelete")))) {
|
||||
store.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmClearHistory() {
|
||||
if (shouldClearHistory(store.entries.length, () => window.confirm(t("history.confirmClear")))) {
|
||||
store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -54,7 +67,7 @@ onMounted(() => store.load());
|
||||
<Clock class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span class="text-xs font-medium">{{ t('history.title') }}</span>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="store.clear()">
|
||||
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('close')">
|
||||
@@ -94,7 +107,7 @@ onMounted(() => store.load());
|
||||
<ContextMenuContent class="w-40">
|
||||
<ContextMenuItem @click="restore(entry.sql)">{{ t('history.restore') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySql(entry.sql)">{{ t('history.copy') }}</ContextMenuItem>
|
||||
<ContextMenuItem class="text-destructive" @click="store.remove(entry.id)">{{ t('history.delete') }}</ContextMenuItem>
|
||||
<ContextMenuItem class="text-destructive" @click="confirmDeleteEntry(entry.id)">{{ t('history.delete') }}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
type CellPosition,
|
||||
type CellSelectionRange,
|
||||
} from "@/lib/gridSelection";
|
||||
import { buildTableSelectSql, normalizeWhereInput } from "@/lib/tableSelectSql";
|
||||
import { buildTableSelectSql, normalizeWhereInput, quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql";
|
||||
import { formatMarkdownTable } from "@/lib/markdownTable";
|
||||
import {
|
||||
matchesRowStatusFilter,
|
||||
rowStatusFilterAfterAddingRow,
|
||||
@@ -388,6 +390,14 @@ function formatCell(value: CellValue): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function quoteIdent(name: string): string {
|
||||
return quoteTableIdentifier(props.databaseType, name);
|
||||
}
|
||||
|
||||
function escapeVal(value: CellValue): string {
|
||||
return formatGridSqlLiteral(value);
|
||||
}
|
||||
|
||||
function isNull(value: unknown): boolean { return value === null; }
|
||||
|
||||
function rowNumberStatusClass(item: RowItem): string {
|
||||
@@ -592,65 +602,17 @@ function deleteSelectedRow() {
|
||||
requestDeleteRow(contextCell.value.rowId);
|
||||
}
|
||||
|
||||
function escapeVal(v: CellValue): string {
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
||||
const s = String(v);
|
||||
if (s === "") return "''";
|
||||
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function quoteIdent(name: string): string {
|
||||
if (props.databaseType === "mysql") {
|
||||
return `\`${name.replace(/`/g, "``")}\``;
|
||||
}
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function qualifiedTableName(): string {
|
||||
if (!props.tableMeta) return "";
|
||||
const { schema, tableName } = props.tableMeta;
|
||||
if ((props.databaseType === "postgres" || props.databaseType === "oracle") && schema) {
|
||||
return `${quoteIdent(schema)}.${quoteIdent(tableName)}`;
|
||||
}
|
||||
return quoteIdent(tableName);
|
||||
}
|
||||
|
||||
function generateSaveStatements(): string[] {
|
||||
if (!props.tableMeta) return [];
|
||||
const { primaryKeys } = props.tableMeta;
|
||||
const cols = props.result.columns;
|
||||
const stmts: string[] = [];
|
||||
const tbl = qualifiedTableName();
|
||||
|
||||
for (const [rowIdx, changes] of dirtyRows.value) {
|
||||
const row = props.result.rows[rowIdx];
|
||||
if (!row) continue;
|
||||
const sets = Array.from(changes.entries())
|
||||
.map(([colIdx, val]) => `${quoteIdent(cols[colIdx])} = ${escapeVal(val)}`)
|
||||
.join(", ");
|
||||
const where = primaryKeys
|
||||
.map((pk) => `${quoteIdent(pk)} = ${escapeVal(row[cols.indexOf(pk)])}`)
|
||||
.join(" AND ");
|
||||
stmts.push(`UPDATE ${tbl} SET ${sets} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const rowIdx of deletedRows.value) {
|
||||
const row = props.result.rows[rowIdx];
|
||||
if (!row) continue;
|
||||
const where = primaryKeys
|
||||
.map((pk) => `${quoteIdent(pk)} = ${escapeVal(row[cols.indexOf(pk)])}`)
|
||||
.join(" AND ");
|
||||
stmts.push(`DELETE FROM ${tbl} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const newRow of newRows.value) {
|
||||
const colNames = cols.map((c) => quoteIdent(c)).join(", ");
|
||||
const vals = newRow.map((v) => escapeVal(v)).join(", ");
|
||||
stmts.push(`INSERT INTO ${tbl} (${colNames}) VALUES (${vals});`);
|
||||
}
|
||||
return stmts;
|
||||
return buildDataGridSaveStatements({
|
||||
databaseType: props.databaseType,
|
||||
tableMeta: props.tableMeta,
|
||||
columns: props.result.columns,
|
||||
rows: props.result.rows,
|
||||
dirtyRows: [...dirtyRows.value.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]),
|
||||
deletedRows: [...deletedRows.value],
|
||||
newRows: newRows.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
@@ -863,16 +825,9 @@ async function exportJson() {
|
||||
}
|
||||
|
||||
async function exportMarkdown() {
|
||||
const pad = (s: string, len: number) => s.padEnd(len);
|
||||
const cols = props.result.columns;
|
||||
const visibleRows = displayItems.value.map((item) => item.data);
|
||||
const widths = cols.map((c, i) => Math.max(c.length, ...visibleRows.map((r) => formatCell(r[i]).length), 3));
|
||||
const header = `| ${cols.map((c, i) => pad(c, widths[i])).join(" | ")} |`;
|
||||
const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
|
||||
const body = visibleRows.map((row) =>
|
||||
`| ${row.map((c, i) => pad(formatCell(c), widths[i])).join(" | ")} |`
|
||||
).join("\n");
|
||||
const md = `${header}\n${sep}\n${body}\n`;
|
||||
const md = formatMarkdownTable({ columns: cols, rows: visibleRows });
|
||||
const path = await savePath({ filters: [{ name: "Markdown", extensions: ["md"] }] });
|
||||
if (path) await writeTextFile(path, md);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Columns3, Eye, History, Link, Loader2, RefreshCw, SearchX, X } from "lucide-vue-next";
|
||||
import { ArrowUpRight, Check, Columns3, Copy, Eye, Filter, History, Link, Loader2, RefreshCw, Search, SearchX, X } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/tauri";
|
||||
import {
|
||||
analyzeFieldLineage,
|
||||
summarizeLineageCounts,
|
||||
type FieldLineageConfidence,
|
||||
type FieldLineageItem,
|
||||
type FieldLineageResult,
|
||||
type FieldLineageTable,
|
||||
@@ -27,9 +30,17 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [value: boolean];
|
||||
"open-target": [value: {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columnName?: string;
|
||||
}];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const dialogOpen = computed({
|
||||
get: () => props.open,
|
||||
@@ -46,6 +57,9 @@ const error = ref("");
|
||||
const progressDone = ref(0);
|
||||
const progressTotal = ref(0);
|
||||
const result = ref<FieldLineageResult | null>(null);
|
||||
const confidenceFilter = ref<"all" | FieldLineageConfidence>("all");
|
||||
const searchText = ref("");
|
||||
const copiedId = ref("");
|
||||
let runId = 0;
|
||||
|
||||
const targetLabel = computed(() => {
|
||||
@@ -55,15 +69,42 @@ const targetLabel = computed(() => {
|
||||
|
||||
const counts = computed(() => summarizeLineageCounts(result.value?.items ?? []));
|
||||
|
||||
const groupedItems = computed(() => ({
|
||||
certain: (result.value?.items ?? []).filter((item) => item.confidence === "certain"),
|
||||
likely: (result.value?.items ?? []).filter((item) => item.confidence === "likely"),
|
||||
possible: (result.value?.items ?? []).filter((item) => item.confidence === "possible"),
|
||||
}));
|
||||
const confidenceOptions = computed<Array<{ value: "all" | FieldLineageConfidence; label: string; count: number }>>(() => [
|
||||
{ value: "all", label: t("lineage.all"), count: result.value?.items.length ?? 0 },
|
||||
{ value: "certain", label: t("lineage.certain"), count: counts.value.certain },
|
||||
{ value: "likely", label: t("lineage.likely"), count: counts.value.likely },
|
||||
{ value: "possible", label: t("lineage.possible"), count: counts.value.possible },
|
||||
]);
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const query = searchText.value.trim().toLowerCase();
|
||||
return (result.value?.items ?? [])
|
||||
.filter((item) => confidenceFilter.value === "all" || item.confidence === confidenceFilter.value)
|
||||
.filter((item) => {
|
||||
if (!query) return true;
|
||||
return [
|
||||
item.title,
|
||||
item.schema,
|
||||
item.table,
|
||||
item.column,
|
||||
item.sqlSnippet,
|
||||
itemKindLabel(item),
|
||||
itemDescription(item),
|
||||
].some((value) => String(value ?? "").toLowerCase().includes(query));
|
||||
})
|
||||
.sort((a, b) => itemRank(a) - itemRank(b));
|
||||
});
|
||||
|
||||
const filteredCounts = computed(() => summarizeLineageCounts(filteredItems.value));
|
||||
|
||||
watch(dialogOpen, (open) => {
|
||||
if (open) void loadLineage();
|
||||
else cancelLoad();
|
||||
if (open) {
|
||||
confidenceFilter.value = "all";
|
||||
searchText.value = "";
|
||||
void loadLineage();
|
||||
} else {
|
||||
cancelLoad();
|
||||
}
|
||||
});
|
||||
|
||||
function cancelLoad() {
|
||||
@@ -170,76 +211,191 @@ function itemIcon(item: FieldLineageItem) {
|
||||
function confidenceVariant(confidence: FieldLineageItem["confidence"]) {
|
||||
return confidence === "certain" ? "default" : "secondary";
|
||||
}
|
||||
|
||||
function confidenceTone(confidence: FieldLineageItem["confidence"]) {
|
||||
if (confidence === "certain") return "text-emerald-600 bg-emerald-50 border-emerald-200";
|
||||
if (confidence === "likely") return "text-blue-600 bg-blue-50 border-blue-200";
|
||||
return "text-zinc-600 bg-zinc-50 border-zinc-200";
|
||||
}
|
||||
|
||||
function itemRank(item: FieldLineageItem) {
|
||||
const confidenceRank = item.confidence === "certain" ? 0 : item.confidence === "likely" ? 10 : 20;
|
||||
const kindRank = item.kind === "foreignKey" ? 0 : item.kind === "viewReference" ? 1 : item.kind === "historyReference" ? 2 : 3;
|
||||
return confidenceRank + kindRank;
|
||||
}
|
||||
|
||||
function itemKindLabel(item: FieldLineageItem) {
|
||||
return t(`lineage.kind.${item.kind}`);
|
||||
}
|
||||
|
||||
function itemPrimaryLabel(item: FieldLineageItem) {
|
||||
if (item.kind === "historyReference") return t("lineage.queryHistory");
|
||||
if (item.table && item.column) return `${item.table}.${item.column}`;
|
||||
if (item.table) return `${item.table}.${props.prefillColumn}`;
|
||||
return item.title;
|
||||
}
|
||||
|
||||
function itemDescription(item: FieldLineageItem) {
|
||||
if (item.kind === "foreignKey") {
|
||||
if (item.direction === "incoming") {
|
||||
return t("lineage.description.foreignKeyIncoming", { target: `${item.table}.${item.column}` });
|
||||
}
|
||||
return t("lineage.description.foreignKeyOutgoing", { target: `${item.table}.${item.column}` });
|
||||
}
|
||||
if (item.kind === "viewReference") {
|
||||
return item.confidence === "likely"
|
||||
? t("lineage.description.viewLikely")
|
||||
: t("lineage.description.viewPossible");
|
||||
}
|
||||
if (item.kind === "historyReference") {
|
||||
return item.confidence === "likely"
|
||||
? t("lineage.description.historyLikely")
|
||||
: t("lineage.description.historyPossible");
|
||||
}
|
||||
return t("lineage.description.sameName");
|
||||
}
|
||||
|
||||
function copyItem(item: FieldLineageItem) {
|
||||
const text = itemPrimaryLabel(item);
|
||||
navigator.clipboard.writeText(text);
|
||||
copiedId.value = item.id;
|
||||
toast(t("lineage.copied"));
|
||||
setTimeout(() => {
|
||||
if (copiedId.value === item.id) copiedId.value = "";
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
function openItemTarget(item: FieldLineageItem) {
|
||||
if (!item.table) return;
|
||||
emit("open-target", {
|
||||
connectionId: props.prefillConnectionId,
|
||||
database: props.prefillDatabase,
|
||||
schema: item.schema || props.prefillSchema,
|
||||
tableName: item.table,
|
||||
columnName: item.column || props.prefillColumn,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogScrollContent class="sm:max-w-[900px] max-h-[86vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Link class="h-4 w-4" />
|
||||
<DialogScrollContent class="h-[78vh] min-h-[560px] max-h-[780px] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden gap-0 p-0 sm:max-w-[980px]">
|
||||
<DialogHeader class="border-b px-6 py-4 pr-12">
|
||||
<DialogTitle class="flex items-center gap-2 text-lg">
|
||||
<Link class="h-5 w-5" />
|
||||
{{ t('lineage.title') }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="font-medium">{{ targetLabel }}</span>
|
||||
<Badge variant="outline">{{ props.prefillDatabase }}</Badge>
|
||||
<Badge v-if="props.prefillSchema" variant="outline">{{ props.prefillSchema }}</Badge>
|
||||
<div class="flex min-h-0 flex-col overflow-hidden">
|
||||
<div class="border-b bg-muted/10 px-6 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">{{ t('lineage.targetField') }}</div>
|
||||
<div class="mt-1 flex min-w-0 flex-wrap items-center gap-2 text-sm">
|
||||
<span class="max-w-[520px] truncate font-semibold">{{ targetLabel }}</span>
|
||||
<Badge variant="outline">{{ props.prefillDatabase }}</Badge>
|
||||
<Badge v-if="props.prefillSchema" variant="outline">{{ props.prefillSchema }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="result" class="flex flex-wrap gap-2 text-xs">
|
||||
<Badge>{{ t('lineage.certain') }} {{ counts.certain }}</Badge>
|
||||
<Badge variant="secondary">{{ t('lineage.likely') }} {{ counts.likely }}</Badge>
|
||||
<Badge variant="outline">{{ t('lineage.possible') }} {{ counts.possible }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="result" class="mt-3 flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="searchText" class="h-8 pl-8 text-sm" :placeholder="t('lineage.searchPlaceholder')" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 overflow-x-auto">
|
||||
<Filter class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Button
|
||||
v-for="option in confidenceOptions"
|
||||
:key="option.value"
|
||||
size="sm"
|
||||
:variant="confidenceFilter === option.value ? 'default' : 'outline'"
|
||||
class="h-8 shrink-0 px-3"
|
||||
@click="confidenceFilter = option.value"
|
||||
>
|
||||
{{ option.label }} {{ option.count }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="rounded border bg-muted/20 p-4">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<div v-if="loading" class="rounded-md border bg-muted/20 p-4">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t('lineage.loading', { done: progressDone, total: progressTotal || '-' }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="rounded border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<div v-else-if="error" class="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="result && result.items.length === 0" class="flex flex-col items-center justify-center gap-2 rounded border py-12 text-sm text-muted-foreground">
|
||||
<div v-else-if="result && result.items.length === 0" class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground">
|
||||
<SearchX class="h-8 w-8" />
|
||||
{{ t('lineage.empty') }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="result">
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<Badge>{{ t('lineage.certain') }} {{ counts.certain }}</Badge>
|
||||
<Badge variant="secondary">{{ t('lineage.likely') }} {{ counts.likely }}</Badge>
|
||||
<Badge variant="outline">{{ t('lineage.possible') }} {{ counts.possible }}</Badge>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t('lineage.showing', { shown: filteredItems.length, total: result.items.length }) }}</span>
|
||||
<span v-if="filteredItems.length">
|
||||
{{ t('lineage.certain') }} {{ filteredCounts.certain }} ·
|
||||
{{ t('lineage.likely') }} {{ filteredCounts.likely }} ·
|
||||
{{ t('lineage.possible') }} {{ filteredCounts.possible }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-for="group in [
|
||||
{ key: 'certain', label: t('lineage.certain'), items: groupedItems.certain },
|
||||
{ key: 'likely', label: t('lineage.likely'), items: groupedItems.likely },
|
||||
{ key: 'possible', label: t('lineage.possible'), items: groupedItems.possible },
|
||||
]"
|
||||
:key="group.key"
|
||||
class="space-y-2"
|
||||
>
|
||||
<h3 v-if="group.items.length" class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ group.label }}</h3>
|
||||
<div v-for="item in group.items" :key="item.id" class="rounded border p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<component :is="itemIcon(item)" class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div v-if="filteredItems.length === 0" class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground">
|
||||
<SearchX class="h-8 w-8" />
|
||||
{{ t('lineage.noFiltered') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="item in filteredItems" :key="item.id" class="rounded-md border bg-background transition-colors hover:bg-muted/25">
|
||||
<div class="flex items-start gap-3 p-3">
|
||||
<div :class="['mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border', confidenceTone(item.confidence)]">
|
||||
<component :is="itemIcon(item)" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium">{{ item.title }}</span>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="item.table"
|
||||
type="button"
|
||||
class="group inline-flex min-w-0 max-w-[560px] items-center gap-1 truncate rounded-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:title="t('lineage.openTable')"
|
||||
@click="openItemTarget(item)"
|
||||
>
|
||||
<span class="truncate">{{ itemPrimaryLabel(item) }}</span>
|
||||
<ArrowUpRight class="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
<span v-else class="max-w-[560px] truncate font-medium">{{ itemPrimaryLabel(item) }}</span>
|
||||
<Badge v-if="item.schema" variant="outline" class="text-[10px]">{{ item.schema }}</Badge>
|
||||
<Badge variant="outline" class="text-[10px]">{{ itemKindLabel(item) }}</Badge>
|
||||
<Badge :variant="confidenceVariant(item.confidence)" class="text-[10px]">{{ t(`lineage.${item.confidence}`) }}</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ item.description }}</p>
|
||||
<pre v-if="item.sqlSnippet" class="mt-2 max-h-24 overflow-auto rounded bg-muted/40 p-2 text-xs whitespace-pre-wrap">{{ item.sqlSnippet }}</pre>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ itemDescription(item) }}</p>
|
||||
<pre v-if="item.sqlSnippet" class="mt-2 max-h-20 overflow-auto rounded-md bg-muted/40 p-2 text-xs whitespace-pre-wrap">{{ item.sqlSnippet }}</pre>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 shrink-0 p-0" :title="t('lineage.copy')" @click="copyItem(item)">
|
||||
<Check v-if="copiedId === item.id" class="h-4 w-4 text-emerald-600" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter class="border-t px-6 py-4">
|
||||
<Button v-if="loading" variant="outline" @click="cancelLoad">
|
||||
<X class="h-4 w-4" />
|
||||
{{ t('lineage.cancel') }}
|
||||
|
||||
@@ -16,6 +16,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import type { TransferProgress } from "@/lib/tauri";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { nextTransferTerminalState } from "@/lib/transferProgressState";
|
||||
import {
|
||||
ArrowRightLeft, Check, X, Loader2, Square, CheckSquare,
|
||||
} from "lucide-vue-next";
|
||||
@@ -237,11 +238,14 @@ async function startTransfer() {
|
||||
transferProgress.value = new Map(transferProgress.value);
|
||||
currentTable.value = progress.table;
|
||||
|
||||
if (progress.status === "done") {
|
||||
overallDone.value = true;
|
||||
} else if (progress.status === "cancelled") {
|
||||
overallCancelled.value = true;
|
||||
}
|
||||
const nextState = nextTransferTerminalState({
|
||||
done: overallDone.value,
|
||||
cancelled: overallCancelled.value,
|
||||
error: overallError.value,
|
||||
}, progress);
|
||||
overallDone.value = nextState.done;
|
||||
overallCancelled.value = nextState.cancelled;
|
||||
overallError.value = nextState.error;
|
||||
});
|
||||
} catch (e: any) {
|
||||
overallError.value = true;
|
||||
@@ -440,6 +444,7 @@ const totalTransferred = computed(() =>
|
||||
</span>
|
||||
<span v-if="overallDone" class="text-green-600 font-medium">{{ t('transfer.completed') }}</span>
|
||||
<span v-else-if="overallCancelled" class="text-yellow-600 font-medium">{{ t('transfer.cancelled') }}</span>
|
||||
<span v-else-if="overallError" class="text-destructive font-medium">{{ t('transfer.failed') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
@@ -496,7 +501,7 @@ const totalTransferred = computed(() =>
|
||||
{{ t('transfer.start') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="overallDone || overallCancelled">
|
||||
<template v-else-if="overallDone || overallCancelled || overallError">
|
||||
<Button size="sm" @click="open = false">
|
||||
{{ t('common.close') }}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user