feat(grid): compare original and edited JSON values

Closes #1638
This commit is contained in:
t8y2 committed 2026-08-24 23:25:24 +00:00
1 parent b3e8709272
commit efba56c8c0
22 files changed
+814 -339

No files matched your search

@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch, type CSSProperties } from "vue";
import { Copy } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import VirtualScrollArea from "@/components/common/VirtualScrollArea.vue";
import { buildTextDiff, type TextDiffKind } from "@/lib/common/textDiff";
export type TextDiffSide = "before" | "after";
const props = withDefaults(
defineProps<{
beforeText: string;
afterText: string;
beforeLabel: string;
afterLabel: string;
copyBeforeTitle: string;
copyAfterTitle: string;
beforeAvailable?: boolean;
afterAvailable?: boolean;
}>(),
{
beforeAvailable: true,
afterAvailable: true,
},
);
const emit = defineEmits<{
copy: [side: TextDiffSide];
}>();
const beforeScrollArea = ref<InstanceType<typeof VirtualScrollArea> | null>(null);
const afterScrollArea = ref<InstanceType<typeof VirtualScrollArea> | null>(null);
const scrollTop = ref(0);
const viewportHeight = ref(0);
let scrollSyncing = false;
let scrollSyncFrame = 0;
const ROW_HEIGHT = 22;
const ROW_BUFFER = 12;
const diffRows = computed(() => {
const rows = buildTextDiff(props.beforeText, props.afterText);
if (!props.beforeAvailable && props.afterAvailable) return rows.map((row) => ({ ...row, kind: "added" as const }));
if (!props.afterAvailable && props.beforeAvailable) return rows.map((row) => ({ ...row, kind: "removed" as const }));
return rows;
});
const visibleStart = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - ROW_BUFFER));
const visibleEnd = computed(() => Math.min(diffRows.value.length, Math.ceil((scrollTop.value + viewportHeight.value) / ROW_HEIGHT) + ROW_BUFFER));
const visibleRows = computed(() => diffRows.value.slice(visibleStart.value, visibleEnd.value).map((row, offset) => ({ row, index: visibleStart.value + offset })));
const longestLineLength = computed(() => {
let maximum = 0;
for (const row of diffRows.value) maximum = Math.max(maximum, row.beforeText.length, row.afterText.length);
return Math.min(20_000, maximum);
});
const textContentStyle = computed<CSSProperties>(() => ({
height: `${Math.max(1, diffRows.value.length * ROW_HEIGHT)}px`,
width: `max(100%, ${Math.max(40, longestLineLength.value + 8)}ch)`,
}));
function rowClass(kind: TextDiffKind, side: TextDiffSide): string {
if (kind === "modified") return "side-by-side-text-row-modified";
if (kind === "added" && side === "after") return "side-by-side-text-row-added";
if (kind === "removed" && side === "before") return "side-by-side-text-row-removed";
return "";
}
function updateViewport(element: HTMLElement) {
scrollTop.value = element.scrollTop;
viewportHeight.value = element.clientHeight;
}
function syncScroll(side: TextDiffSide, source: HTMLElement) {
updateViewport(source);
if (scrollSyncing) return;
const target = side === "before" ? afterScrollArea.value?.scrollerElement() : beforeScrollArea.value?.scrollerElement();
if (!target) return;
scrollSyncing = true;
target.scrollTop = source.scrollTop;
target.scrollLeft = source.scrollLeft;
if (scrollSyncFrame) cancelAnimationFrame(scrollSyncFrame);
scrollSyncFrame = requestAnimationFrame(() => {
scrollSyncFrame = 0;
scrollSyncing = false;
});
}
function onResize(element: HTMLElement) {
viewportHeight.value = Math.max(viewportHeight.value, element.clientHeight);
}
async function resetScroll() {
scrollTop.value = 0;
await nextTick();
beforeScrollArea.value?.scrollerElement()?.scrollTo({ top: 0, left: 0 });
afterScrollArea.value?.scrollerElement()?.scrollTo({ top: 0, left: 0 });
}
watch(() => [props.beforeText, props.afterText], resetScroll);
onUnmounted(() => {
if (scrollSyncFrame) cancelAnimationFrame(scrollSyncFrame);
});
</script>
<template>
<div class="grid min-h-0 flex-1 grid-cols-2 overflow-hidden">
<section class="flex min-h-0 min-w-0 flex-col border-r">
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-2 text-[11px]">
<span class="min-w-0 flex-1 truncate font-mono text-muted-foreground" :title="beforeLabel">{{ beforeLabel }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="!beforeAvailable" :title="copyBeforeTitle" @click="emit('copy', 'before')"><Copy class="h-3 w-3" /></Button>
</div>
<VirtualScrollArea ref="beforeScrollArea" class="min-h-0 flex-1" scroller-class="side-by-side-text-before-scroller" @scroll="syncScroll('before', $event)" @resize="onResize">
<div class="side-by-side-text-content" :style="textContentStyle">
<div v-for="visible in visibleRows" :key="`before-${visible.index}`" class="side-by-side-text-row" :class="rowClass(visible.row.kind, 'before')" :style="{ top: `${visible.index * ROW_HEIGHT}px`, height: `${ROW_HEIGHT}px` }">
<span class="side-by-side-text-line-number">{{ visible.row.beforeLineNumber ?? "" }}</span>
<code class="side-by-side-text-code"
><template v-for="(segment, index) in visible.row.beforeSegments" :key="index"
><span :class="{ 'side-by-side-text-segment-changed': segment.changed }">{{ segment.text }}</span></template
>&nbsp;</code
>
</div>
</div>
</VirtualScrollArea>
</section>
<section class="flex min-h-0 min-w-0 flex-col">
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-2 text-[11px]">
<span class="min-w-0 flex-1 truncate font-mono text-muted-foreground" :title="afterLabel">{{ afterLabel }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="!afterAvailable" :title="copyAfterTitle" @click="emit('copy', 'after')"><Copy class="h-3 w-3" /></Button>
</div>
<VirtualScrollArea ref="afterScrollArea" class="min-h-0 flex-1" scroller-class="side-by-side-text-after-scroller" @scroll="syncScroll('after', $event)" @resize="onResize">
<div class="side-by-side-text-content" :style="textContentStyle">
<div v-for="visible in visibleRows" :key="`after-${visible.index}`" class="side-by-side-text-row" :class="rowClass(visible.row.kind, 'after')" :style="{ top: `${visible.index * ROW_HEIGHT}px`, height: `${ROW_HEIGHT}px` }">
<span class="side-by-side-text-line-number">{{ visible.row.afterLineNumber ?? "" }}</span>
<code class="side-by-side-text-code"
><template v-for="(segment, index) in visible.row.afterSegments" :key="index"
><span :class="{ 'side-by-side-text-segment-changed': segment.changed }">{{ segment.text }}</span></template
>&nbsp;</code
>
</div>
</div>
</VirtualScrollArea>
</section>
</div>
</template>
<style scoped>
.side-by-side-text-content {
position: relative;
min-width: 100%;
font-family: var(--font-mono);
font-size: 12px;
}
.side-by-side-text-row {
position: absolute;
left: 0;
display: flex;
width: 100%;
min-width: 100%;
align-items: center;
border-bottom: 1px solid color-mix(in srgb, var(--border) 45%, transparent);
line-height: 21px;
}
.side-by-side-text-line-number {
position: sticky;
left: 0;
z-index: 1;
width: 48px;
height: 100%;
flex: 0 0 48px;
border-right: 1px solid var(--border);
background: var(--background);
padding-right: 8px;
text-align: right;
color: var(--muted-foreground);
user-select: none;
}
.side-by-side-text-code {
display: block;
min-width: 0;
padding: 0 8px;
white-space: pre;
font-family: inherit;
}
.side-by-side-text-row-modified {
background: rgb(217 119 6 / 0.11);
}
.side-by-side-text-row-added {
background: rgb(22 163 74 / 0.11);
}
.side-by-side-text-row-removed {
background: rgb(220 38 38 / 0.1);
}
.side-by-side-text-row-modified .side-by-side-text-segment-changed {
background: rgb(217 119 6 / 0.32);
}
</style>
@@ -170,7 +170,7 @@ onUnmounted(() => {
</script>
<template>
<div class="dolt-scroll-area">
<div class="virtual-scroll-area">
<div ref="scroller" class="dolt-scroll-area-scroller" :class="props.scrollerClass" @scroll.passive="onScroll">
<slot />
</div>
@@ -184,7 +184,7 @@ onUnmounted(() => {
</template>
<style scoped>
.dolt-scroll-area {
.virtual-scroll-area {
position: relative;
display: flex;
min-width: 0;
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch, type CSSProperties } from "vue";
import { AlertCircle, Copy, FileDiff } from "@lucide/vue";
import { computed, ref, watch } from "vue";
import { AlertCircle, FileDiff } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DoltScrollArea from "@/components/dolt/DoltScrollArea.vue";
import SideBySideTextDiff, { type TextDiffSide } from "@/components/common/SideBySideTextDiff.vue";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import { buildDoltTextDiff, detectDoltCellFormat, doltCellCopyText, doltCellDisplayValue, formatDoltCellText, type DoltCellFormat, type DoltCellFormatMode, type DoltCellSide, type DoltDiffCellTarget, type DoltTextDiffKind } from "@/lib/dolt/doltCellDiff";
import { detectDoltCellFormat, doltCellCopyText, doltCellDisplayValue, formatDoltCellText, type DoltCellFormat, type DoltCellFormatMode, type DoltDiffCellTarget } from "@/lib/dolt/doltCellDiff";
const props = defineProps<{
target: DoltDiffCellTarget | null;
@@ -20,15 +20,6 @@ const open = defineModel<boolean>("open", { default: false });
const { t } = useI18n();
const { toast } = useToast();
const formatMode = ref<DoltCellFormatMode>("auto");
const beforeScrollArea = ref<InstanceType<typeof DoltScrollArea> | null>(null);
const afterScrollArea = ref<InstanceType<typeof DoltScrollArea> | null>(null);
const scrollTop = ref(0);
const viewportHeight = ref(0);
let scrollSyncing = false;
let scrollSyncFrame = 0;
const ROW_HEIGHT = 22;
const ROW_BUFFER = 12;
const displayLabels = computed(() => ({
nullValue: t("doltVersionControl.cellNullValue"),
@@ -41,24 +32,6 @@ const detectedFormat = computed<DoltCellFormat>(() => detectDoltCellFormat([befo
const effectiveFormat = computed<DoltCellFormat>(() => (formatMode.value === "auto" ? detectedFormat.value : formatMode.value));
const beforeFormatted = computed(() => (beforeDisplay.value ? formatDoltCellText(beforeDisplay.value, effectiveFormat.value) : { text: "", error: null }));
const afterFormatted = computed(() => (afterDisplay.value ? formatDoltCellText(afterDisplay.value, effectiveFormat.value) : { text: "", error: null }));
const diffRows = computed(() => {
const rows = buildDoltTextDiff(beforeFormatted.value.text, afterFormatted.value.text);
if (beforeDisplay.value?.state !== "value" && afterDisplay.value?.state === "value") return rows.map((row) => ({ ...row, kind: "added" as const }));
if (afterDisplay.value?.state !== "value" && beforeDisplay.value?.state === "value") return rows.map((row) => ({ ...row, kind: "removed" as const }));
return rows;
});
const visibleStart = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - ROW_BUFFER));
const visibleEnd = computed(() => Math.min(diffRows.value.length, Math.ceil((scrollTop.value + viewportHeight.value) / ROW_HEIGHT) + ROW_BUFFER));
const visibleRows = computed(() => diffRows.value.slice(visibleStart.value, visibleEnd.value).map((row, offset) => ({ row, index: visibleStart.value + offset })));
const longestLineLength = computed(() => {
let maximum = 0;
for (const row of diffRows.value) maximum = Math.max(maximum, row.beforeText.length, row.afterText.length);
return Math.min(20_000, maximum);
});
const textContentStyle = computed<CSSProperties>(() => ({
height: `${Math.max(1, diffRows.value.length * ROW_HEIGHT)}px`,
width: `max(100%, ${Math.max(40, longestLineLength.value + 8)}ch)`,
}));
const formatOptions = computed<Array<{ value: DoltCellFormatMode; label: string }>>(() => [
{ value: "auto", label: t("doltVersionControl.cellDiffFormatAuto") },
{ value: "raw", label: t("doltVersionControl.cellDiffFormatRaw") },
@@ -66,38 +39,7 @@ const formatOptions = computed<Array<{ value: DoltCellFormatMode; label: string
{ value: "xml", label: "XML" },
]);
function rowClass(kind: DoltTextDiffKind, side: DoltCellSide): string {
if (kind === "modified") return "dolt-cell-text-row-modified";
if (kind === "added" && side === "after") return "dolt-cell-text-row-added";
if (kind === "removed" && side === "before") return "dolt-cell-text-row-removed";
return "";
}
function updateViewport(element: HTMLElement) {
scrollTop.value = element.scrollTop;
viewportHeight.value = element.clientHeight;
}
function syncScroll(side: DoltCellSide, source: HTMLElement) {
updateViewport(source);
if (scrollSyncing) return;
const target = side === "before" ? afterScrollArea.value?.scrollerElement() : beforeScrollArea.value?.scrollerElement();
if (!target) return;
scrollSyncing = true;
target.scrollTop = source.scrollTop;
target.scrollLeft = source.scrollLeft;
if (scrollSyncFrame) cancelAnimationFrame(scrollSyncFrame);
scrollSyncFrame = requestAnimationFrame(() => {
scrollSyncFrame = 0;
scrollSyncing = false;
});
}
function onResize(element: HTMLElement) {
viewportHeight.value = Math.max(viewportHeight.value, element.clientHeight);
}
async function copyValue(side: DoltCellSide) {
async function copyValue(side: TextDiffSide) {
if (!props.target) return;
const value = doltCellCopyText(props.target, side);
if (value === null) return;
@@ -111,28 +53,11 @@ async function copyValue(side: DoltCellSide) {
watch(
() => [open.value, props.target?.rowIndex, props.target?.columnIndex, props.target?.side],
async ([isOpen]) => {
([isOpen]) => {
if (!isOpen) return;
formatMode.value = "auto";
scrollTop.value = 0;
await nextTick();
const before = beforeScrollArea.value?.scrollerElement();
const after = afterScrollArea.value?.scrollerElement();
if (before) before.scrollTo({ top: 0, left: 0 });
if (after) after.scrollTo({ top: 0, left: 0 });
},
);
watch(effectiveFormat, async () => {
scrollTop.value = 0;
await nextTick();
beforeScrollArea.value?.scrollerElement()?.scrollTo({ top: 0, left: 0 });
afterScrollArea.value?.scrollerElement()?.scrollTo({ top: 0, left: 0 });
});
onUnmounted(() => {
if (scrollSyncFrame) cancelAnimationFrame(scrollSyncFrame);
});
</script>
<template>
@@ -167,104 +92,17 @@ onUnmounted(() => {
</div>
</div>
<div class="grid min-h-0 flex-1 grid-cols-2 overflow-hidden">
<section class="flex min-w-0 min-h-0 flex-col border-r">
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-2 text-[11px]">
<span class="min-w-0 flex-1 truncate font-mono text-muted-foreground" :title="fromRevision">{{ fromRevision }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="doltCellCopyText(target, 'before') === null" :title="t('doltVersionControl.copyBeforeValue')" @click="copyValue('before')"><Copy class="h-3 w-3" /></Button>
</div>
<DoltScrollArea ref="beforeScrollArea" class="min-h-0 flex-1" scroller-class="dolt-cell-diff-before-scroller" @scroll="syncScroll('before', $event)" @resize="onResize">
<div class="dolt-cell-text-content" :style="textContentStyle">
<div v-for="visible in visibleRows" :key="`before-${visible.index}`" class="dolt-cell-text-row" :class="rowClass(visible.row.kind, 'before')" :style="{ top: `${visible.index * ROW_HEIGHT}px`, height: `${ROW_HEIGHT}px` }">
<span class="dolt-cell-text-line-number">{{ visible.row.beforeLineNumber ?? "" }}</span>
<code class="dolt-cell-text-code"
><template v-for="(segment, index) in visible.row.beforeSegments" :key="index"
><span :class="{ 'dolt-cell-text-segment-changed': segment.changed }">{{ segment.text }}</span></template
>&nbsp;</code
>
</div>
</div>
</DoltScrollArea>
</section>
<section class="flex min-w-0 min-h-0 flex-col">
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-2 text-[11px]">
<span class="min-w-0 flex-1 truncate font-mono text-muted-foreground" :title="toRevision">{{ toRevision }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="doltCellCopyText(target, 'after') === null" :title="t('doltVersionControl.copyAfterValue')" @click="copyValue('after')"><Copy class="h-3 w-3" /></Button>
</div>
<DoltScrollArea ref="afterScrollArea" class="min-h-0 flex-1" scroller-class="dolt-cell-diff-after-scroller" @scroll="syncScroll('after', $event)" @resize="onResize">
<div class="dolt-cell-text-content" :style="textContentStyle">
<div v-for="visible in visibleRows" :key="`after-${visible.index}`" class="dolt-cell-text-row" :class="rowClass(visible.row.kind, 'after')" :style="{ top: `${visible.index * ROW_HEIGHT}px`, height: `${ROW_HEIGHT}px` }">
<span class="dolt-cell-text-line-number">{{ visible.row.afterLineNumber ?? "" }}</span>
<code class="dolt-cell-text-code"
><template v-for="(segment, index) in visible.row.afterSegments" :key="index"
><span :class="{ 'dolt-cell-text-segment-changed': segment.changed }">{{ segment.text }}</span></template
>&nbsp;</code
>
</div>
</div>
</DoltScrollArea>
</section>
</div>
<SideBySideTextDiff
:before-text="beforeFormatted.text"
:after-text="afterFormatted.text"
:before-label="fromRevision"
:after-label="toRevision"
:copy-before-title="t('doltVersionControl.copyBeforeValue')"
:copy-after-title="t('doltVersionControl.copyAfterValue')"
:before-available="beforeDisplay?.state === 'value'"
:after-available="afterDisplay?.state === 'value'"
@copy="copyValue"
/>
</DialogContent>
</Dialog>
</template>
<style scoped>
.dolt-cell-text-content {
position: relative;
min-width: 100%;
font-family: var(--font-mono);
font-size: 12px;
}
.dolt-cell-text-row {
position: absolute;
left: 0;
display: flex;
width: 100%;
min-width: 100%;
align-items: center;
border-bottom: 1px solid color-mix(in srgb, var(--border) 45%, transparent);
line-height: 21px;
}
.dolt-cell-text-line-number {
position: sticky;
left: 0;
z-index: 1;
width: 48px;
height: 100%;
flex: 0 0 48px;
border-right: 1px solid var(--border);
background: var(--background);
padding-right: 8px;
text-align: right;
color: var(--muted-foreground);
user-select: none;
}
.dolt-cell-text-code {
display: block;
min-width: 0;
padding: 0 8px;
white-space: pre;
font-family: inherit;
}
.dolt-cell-text-row-modified {
background: rgb(217 119 6 / 0.11);
}
.dolt-cell-text-row-added {
background: rgb(22 163 74 / 0.11);
}
.dolt-cell-text-row-removed {
background: rgb(220 38 38 / 0.1);
}
.dolt-cell-text-row-modified .dolt-cell-text-segment-changed {
background: rgb(217 119 6 / 0.32);
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from "vue";
import DoltScrollArea from "@/components/dolt/DoltScrollArea.vue";
import VirtualScrollArea from "@/components/common/VirtualScrollArea.vue";
import type { QueryResult } from "@/types/database";
import type { DoltDiffColumnKind, DoltDiffRow } from "@/lib/dolt/doltVersionControl";
import type { DoltDiffCellTarget } from "@/lib/dolt/doltCellDiff";
@@ -32,7 +32,7 @@ const COLUMN_BUFFER = 240;
const minimumColumnWidth = 72;
const maximumColumnWidth = 360;
const scrollArea = ref<InstanceType<typeof DoltScrollArea> | null>(null);
const scrollArea = ref<InstanceType<typeof VirtualScrollArea> | null>(null);
const measuredWidths = ref<number[]>([]);
const scrollTop = ref(0);
const scrollLeft = ref(0);
@@ -227,7 +227,7 @@ onUnmounted(() => {
</script>
<template>
<DoltScrollArea ref="scrollArea" class="min-h-0 flex-1" scroller-class="dolt-diff-table-scroller" @scroll="onScroll" @resize="updateViewport">
<VirtualScrollArea ref="scrollArea" class="min-h-0 flex-1" scroller-class="dolt-diff-table-scroller" @scroll="onScroll" @resize="updateViewport">
<div class="dolt-diff-table-content" :style="contentStyle">
<div class="dolt-diff-table-header" :style="{ width: `${Math.max(totalWidth, 1)}px`, height: '28px' }">
<div v-for="column in visibleColumns" :key="`header-${column.index}-${column.column}`" class="dolt-diff-table-header-cell" :class="headerClass?.(column.index, column.column)" :data-column-kind="column.kind" :style="{ left: `${column.left}px`, width: `${column.width}px` }">
@@ -258,7 +258,7 @@ onUnmounted(() => {
</div>
<div v-if="rows.length === 0" class="dolt-diff-table-empty">{{ $t("doltVersionControl.noRowChanges") }}</div>
</div>
</DoltScrollArea>
</VirtualScrollArea>
</template>
<style scoped>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { Check, ChevronDown, GitBranch, Tag } from "@lucide/vue";
import DoltScrollArea from "@/components/dolt/DoltScrollArea.vue";
import VirtualScrollArea from "@/components/common/VirtualScrollArea.vue";
import type { DoltRef } from "@/lib/dolt/doltVersionControl";
const props = defineProps<{
@@ -153,7 +153,7 @@ onUnmounted(() => document.removeEventListener("pointerdown", onDocumentPointerD
</div>
<div v-if="open && filteredOptions.length" class="dolt-revision-selector-options" role="listbox">
<DoltScrollArea class="max-h-[240px]" scroller-class="p-[3px]">
<VirtualScrollArea class="max-h-[240px]" scroller-class="p-[3px]">
<button
v-for="(option, index) in filteredOptions"
:key="`${option.kind}:${option.name}`"
@@ -170,7 +170,7 @@ onUnmounted(() => document.removeEventListener("pointerdown", onDocumentPointerD
<span class="min-w-0 flex-1 truncate font-mono text-[11px]">{{ option.name }}</span>
<Check v-if="option.name === modelValue" class="h-3.5 w-3.5 text-primary" />
</button>
</DoltScrollArea>
</VirtualScrollArea>
</div>
</div>
</template>
@@ -14,7 +14,7 @@ import DoltDiffPagination from "@/components/dolt/DoltDiffPagination.vue";
import DoltDiffTable from "@/components/dolt/DoltDiffTable.vue";
import DoltCellDiffDialog from "@/components/dolt/DoltCellDiffDialog.vue";
import DoltRevisionSelector from "@/components/dolt/DoltRevisionSelector.vue";
import DoltScrollArea from "@/components/dolt/DoltScrollArea.vue";
import VirtualScrollArea from "@/components/common/VirtualScrollArea.vue";
import * as api from "@/lib/backend/api";
import { filterDatabaseNamesForVisiblePicker } from "@/lib/database/visibleDatabases";
import type { QueryResult } from "@/types/database";
@@ -1264,7 +1264,7 @@ onUnmounted(() => {
<span class="truncate">{{ t("doltVersionControl.changedTables") }}</span>
<span v-if="changes.length" class="ml-auto text-[11px] text-muted-foreground">{{ changes.length }}</span>
</div>
<DoltScrollArea class="min-h-0 flex-1">
<VirtualScrollArea class="min-h-0 flex-1">
<div v-if="comparisonLoading && changes.length === 0" class="flex h-24 items-center justify-center gap-2 text-xs text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("doltVersionControl.loadingDiff") }}</div>
<div v-else-if="comparisonError" class="p-3 text-xs text-destructive">{{ comparisonError }}</div>
<div v-else-if="changes.length === 0" class="flex h-24 items-center justify-center px-3 text-center text-xs text-muted-foreground">{{ t("doltVersionControl.noChanges") }}</div>
@@ -1276,7 +1276,7 @@ onUnmounted(() => {
<span v-for="flag in changeFlags(change)" :key="flag" class="dolt-change-flag">{{ changeFlagSymbol(flag) }}</span>
</span>
</button>
</DoltScrollArea>
</VirtualScrollArea>
</section>
</Pane>
@@ -1376,7 +1376,7 @@ onUnmounted(() => {
</TabsList>
</div>
<TabsContent value="branches" class="m-0 min-h-0 flex-1 overflow-hidden">
<DoltScrollArea class="h-full min-h-0" scroller-class="py-1 text-xs">
<VirtualScrollArea class="h-full min-h-0" scroller-class="py-1 text-xs">
<template v-if="branchTreeView">
<CustomContextMenu v-for="row in branchTreeRows" :key="`branch-tree-${row.key}`" :items="() => branchTreeRowContextMenuItems(row)" v-slot="{ onContextMenu, isOpen }">
<button
@@ -1419,10 +1419,10 @@ onUnmounted(() => {
</CustomContextMenu>
</template>
<div v-if="visibleBranches.length === 0" class="px-7 py-2 text-muted-foreground">{{ t("doltVersionControl.noBranches") }}</div>
</DoltScrollArea>
</VirtualScrollArea>
</TabsContent>
<TabsContent value="tags" class="m-0 min-h-0 flex-1 overflow-hidden">
<DoltScrollArea class="h-full min-h-0" scroller-class="py-1 text-xs">
<VirtualScrollArea class="h-full min-h-0" scroller-class="py-1 text-xs">
<CustomContextMenu v-for="item in visibleTags" :key="`tag-${item.name}`" :items="() => refContextMenuItems(item)" v-slot="{ onContextMenu, isOpen }">
<button
type="button"
@@ -1436,14 +1436,14 @@ onUnmounted(() => {
</button>
</CustomContextMenu>
<div v-if="visibleTags.length === 0" class="px-7 py-2 text-muted-foreground">{{ t("doltVersionControl.noTags") }}</div>
</DoltScrollArea>
</VirtualScrollArea>
</TabsContent>
</Tabs>
</aside>
</Pane>
<Pane :size="100 - doltLeftPaneSize" :min-size="100 - DOLT_LEFT_PANE_MAX_SIZE">
<DoltScrollArea class="relative h-full min-h-0">
<VirtualScrollArea class="relative h-full min-h-0">
<div class="dolt-commit-header sticky top-0 z-20 grid h-7 items-center border-b bg-muted/80 text-[11px] font-medium text-muted-foreground backdrop-blur" :style="{ gridTemplateColumns: `${graphWidth}px minmax(240px, 1fr) 150px 155px` }">
<span class="px-2">{{ t("doltVersionControl.graph") }}</span>
<span class="px-2">{{ t("doltVersionControl.commit") }}</span>
@@ -1512,7 +1512,7 @@ onUnmounted(() => {
</button>
</CustomContextMenu>
</div>
</DoltScrollArea>
</VirtualScrollArea>
</Pane>
</Splitpanes>
</Pane>
+40 -1
View File
@@ -151,6 +151,7 @@ import {
} from "@/lib/dataGrid/binaryCellDownload";
import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer";
import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, defaultCellDetailTab, formatJsonText, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, valueEditorActions, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation";
import { createJsonValueDiffSnapshot, isJsonValueDiffAvailable, type JsonValueDiffContext, type JsonValueDiffSnapshot } from "@/lib/dataGrid/jsonValueDiff";
import {
buildDataGridCellDetail,
buildDataGridColumnDetail,
@@ -361,6 +362,7 @@ dayjs.extend(timezone);
const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue"));
const ImagePreviewDialog = defineAsyncComponent(() => import("@/components/grid/ImagePreviewDialog.vue"));
const DataGridCellDetailDialog = defineAsyncComponent(() => import("@/components/grid/DataGridCellDetailDialog.vue"));
const DataGridValueDiffDialog = defineAsyncComponent(() => import("@/components/grid/DataGridValueDiffDialog.vue"));
const DataGridMongoJsonPreview = defineAsyncComponent(() => import("@/components/grid/DataGridMongoJsonPreview.vue"));
const DataGridDetailDialogs = defineAsyncComponent(() => import("@/components/grid/DataGridDetailDialogs.vue"));
const DataGridBulkEditDialog = defineAsyncComponent(() => import("@/components/grid/DataGridBulkEditDialog.vue"));
@@ -5972,7 +5974,25 @@ watch(activeCellDetailTab, (tab) => {
const detailEditValue = ref("");
const detailEditOriginalValue = ref("");
const isEditingDetail = ref(false);
const detailValueDiffOpen = ref(false);
const detailValueDiffSnapshot = ref<Readonly<JsonValueDiffSnapshot> | null>(null);
const hasPendingDetailEditorDraft = computed(() => isEditingDetail.value && detailEditValue.value !== detailEditOriginalValue.value);
const detailJsonDiffContext = computed<JsonValueDiffContext | null>(() => {
const detail = activeCellDetail.value;
if (!detail) return null;
return {
columnName: detail.column,
columnType: detail.type,
originalValue: detailEditOriginalValue.value,
isEditable: detail.isEditable,
isEditing: isEditingDetail.value,
};
});
const showDetailJsonCompare = computed(() => {
const context = detailJsonDiffContext.value;
return !!context && isJsonValueDiffAvailable(context);
});
const canCompareDetailJson = computed(() => showDetailJsonCompare.value && hasPendingDetailEditorDraft.value);
const hasPendingInlineEditorDraft = computed(() => {
const cell = editingCell.value;
if (!cell) return false;
@@ -6141,7 +6161,9 @@ watch(valueEditorContainer, async (el) => {
detailEditValue.value = v;
},
onEscape: () => restoreDetailOriginalValue(),
onBlur: () => commitValueEditorEdit(),
onBlur: () => {
if (!detailValueDiffOpen.value) commitValueEditorEdit();
},
editorTheme: editorThemeAccessor,
appAppearance: editorAppAppearance,
appPalette: editorAppPalette,
@@ -6316,6 +6338,15 @@ function compactDetailJson() {
syncEditorFromDetailEdit();
}
function openDetailJsonCompare() {
const context = detailJsonDiffContext.value;
if (!context) return;
const snapshot = createJsonValueDiffSnapshot({ ...context, currentValue: detailEditValue.value });
if (!snapshot) return;
detailValueDiffSnapshot.value = snapshot;
detailValueDiffOpen.value = true;
}
function setDetailNull() {
const detail = activeCellDetail.value;
if (!detail || !detail.isEditable) return;
@@ -13420,6 +13451,8 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
:side-json-view="sideDetailJsonView"
:show-compact-json="showCompactDetailJson"
:can-compact-json="canCompactDetailJson"
:show-compare-json="showDetailJsonCompare"
:can-compare-json="canCompareDetailJson"
:type-color-class="typeColorClass"
:can-download-binary-value="canDownloadDetailBinaryValue"
:download-binary-value="downloadDetailBinaryValue"
@@ -13430,6 +13463,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
:database-type="resolvedDatabaseType"
@start-edit="startDetailEdit"
@compact-json="compactDetailJson"
@compare-json="openDetailJsonCompare"
@toggle-formatted="toggleCellDetailJsonFormatted"
@copy-value="copyDetailCurrentValue"
@commit="commitDetailEdit"
@@ -13506,6 +13540,9 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
<Button v-if="activeValueEditorActions.includes('compactJson')" variant="outline" size="sm" class="h-6 text-xs" @mousedown.prevent @click="compactDetailJson">
{{ t("grid.compactJson") }}
</Button>
<Button v-if="showDetailJsonCompare" variant="outline" size="sm" class="h-6 text-xs" :disabled="!canCompareDetailJson" @mousedown.prevent @click="openDetailJsonCompare">
{{ t("grid.compareJson") }}
</Button>
<Button v-if="activeValueEditorActions.includes('setNull')" variant="outline" size="sm" class="h-6 text-xs" @mousedown.prevent @click="setValueEditorNull">
{{ t("grid.setNull") }}
</Button>
@@ -13622,6 +13659,8 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
@edit="openDialogCellInSidePanel"
/>
<DataGridValueDiffDialog v-if="detailValueDiffSnapshot" v-model:open="detailValueDiffOpen" :snapshot="detailValueDiffSnapshot" />
<DataGridDetailDialogs
v-if="detailDialogsMounted"
v-model:row-open="rowDetailDialogOpen"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, toRef } from "vue";
import { Code2, Copy, Download, Eye, FileUp, Pencil, X } from "@lucide/vue";
import { Code2, Copy, Download, Eye, FileDiff, FileUp, Pencil, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -28,6 +28,8 @@ const props = defineProps<{
sideJsonView: boolean;
showCompactJson: boolean;
canCompactJson: boolean;
showCompareJson?: boolean;
canCompareJson?: boolean;
typeColorClass: (type: string) => string;
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
@@ -44,6 +46,7 @@ const detailEditValue = defineModel<string>("value", { default: "" });
const emit = defineEmits<{
startEdit: [];
compactJson: [];
compareJson: [];
toggleFormatted: [];
copyValue: [];
commit: [];
@@ -138,6 +141,7 @@ defineExpose({ openSearch });
<div class="flex min-h-5 items-center justify-between gap-2">
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
<div class="flex items-center gap-1">
<Button v-if="editing && showCompareJson" variant="ghost" size="sm" class="h-5 gap-1 px-1.5 text-xs" :disabled="!canCompareJson" :title="t('grid.compareJson')" @mousedown.prevent @click="emit('compareJson')"><FileDiff class="h-3 w-3" />{{ t("grid.compareJson") }}</Button>
<Button v-if="showCompactJson" variant="ghost" size="sm" class="h-5 gap-1 px-1.5 text-xs" :disabled="!canCompactJson" :title="t('grid.compactJson')" @click="emit('compactJson')"><Code2 class="h-3 w-3" />{{ t("grid.compactJson") }}</Button>
<Button v-if="!editing && detail.formattedJson" :variant="sideJsonView ? 'secondary' : 'ghost'" size="sm" class="h-5 gap-1 px-1.5 text-xs" :title="t('grid.formattedJson')" @click="emit('toggleFormatted')"><Code2 class="h-3 w-3" />{{ t("grid.formattedJson") }}</Button>
<Button v-if="!editing && detail.isEditable" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.editValue')" @click="emit('startEdit')"><Pencil class="h-3 w-3" /></Button>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { AlertCircle, FileDiff } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import SideBySideTextDiff, { type TextDiffSide } from "@/components/common/SideBySideTextDiff.vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatJsonValueDiffText, type JsonValueDiffFormatError, type JsonValueDiffMode, type JsonValueDiffSnapshot } from "@/lib/dataGrid/jsonValueDiff";
const props = defineProps<{
snapshot: Readonly<JsonValueDiffSnapshot> | null;
}>();
const open = defineModel<boolean>("open", { default: false });
const { t } = useI18n();
const { toast } = useToast();
const formatMode = ref<JsonValueDiffMode>("json");
const originalFormatted = computed(() => formatJsonValueDiffText(props.snapshot?.originalValue ?? "", formatMode.value));
const currentFormatted = computed(() => formatJsonValueDiffText(props.snapshot?.currentValue ?? "", formatMode.value));
function formatError(error: JsonValueDiffFormatError): string {
if (error.kind === "too-large") return t("grid.valueDiffTooLarge", { limit: error.limit.toLocaleString() });
return t("grid.valueDiffInvalidJson", { message: error.message });
}
async function copyValue(side: TextDiffSide) {
const snapshot = props.snapshot;
if (!snapshot) return;
try {
await copyToClipboard(side === "before" ? snapshot.originalValue : snapshot.currentValue);
toast(t("grid.cellValueCopied"), 2000);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 5000);
}
}
watch(open, (isOpen) => {
if (isOpen) formatMode.value = "json";
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent v-if="snapshot" class="flex h-[min(82vh,760px)] min-w-0 max-w-[min(1180px,calc(100vw-32px))] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(1180px,calc(100vw-32px))]">
<DialogHeader class="shrink-0 border-b px-4 py-3 pr-11">
<DialogTitle class="flex min-w-0 items-center gap-2 text-sm">
<FileDiff class="h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400" />
<span class="min-w-0 truncate">{{ t("grid.valueDiffTitle", { column: snapshot.columnName }) }}</span>
</DialogTitle>
</DialogHeader>
<div class="flex h-9 shrink-0 items-center gap-3 border-b px-3">
<div class="inline-flex h-7 items-center rounded border bg-muted/20 p-0.5" role="group" :aria-label="t('grid.valueDiffFormat')">
<Button type="button" size="sm" :variant="formatMode === 'json' ? 'secondary' : 'ghost'" class="h-6 rounded-sm px-2 text-[11px] shadow-none" @click="formatMode = 'json'">JSON</Button>
<Button type="button" size="sm" :variant="formatMode === 'raw' ? 'secondary' : 'ghost'" class="h-6 rounded-sm px-2 text-[11px] shadow-none" @click="formatMode = 'raw'">{{ t("grid.valueDiffRaw") }}</Button>
</div>
</div>
<div v-if="originalFormatted.error || currentFormatted.error" class="grid shrink-0 grid-cols-2 border-b bg-amber-500/10 text-[11px] text-amber-700 dark:text-amber-300">
<div class="flex min-w-0 items-start gap-1.5 border-r px-2 py-1">
<template v-if="originalFormatted.error"
><AlertCircle class="mt-0.5 h-3 w-3 shrink-0" /><span class="min-w-0 break-words">{{ formatError(originalFormatted.error) }}</span></template
>
</div>
<div class="flex min-w-0 items-start gap-1.5 px-2 py-1">
<template v-if="currentFormatted.error"
><AlertCircle class="mt-0.5 h-3 w-3 shrink-0" /><span class="min-w-0 break-words">{{ formatError(currentFormatted.error) }}</span></template
>
</div>
</div>
<SideBySideTextDiff
:before-text="originalFormatted.text"
:after-text="currentFormatted.text"
:before-label="t('grid.valueDiffOriginal')"
:after-label="t('grid.valueDiffCurrent')"
:copy-before-title="t('grid.copyOriginalValue')"
:copy-after-title="t('grid.copyCurrentValue')"
@copy="copyValue"
/>
</DialogContent>
</Dialog>
</template>
@@ -28,6 +28,7 @@ vi.mock("@lucide/vue", async () => {
ChevronsRight: icon,
Download: icon,
Filter: icon,
FileDiff: icon,
Loader2: icon,
FileUp: icon,
Upload: icon,
@@ -1434,6 +1435,49 @@ describe("cell detail surfaces", () => {
doubleClickCapture({ target: { closest: () => null }, clientX: 60, clientY: 80 });
expect(startEdit).toHaveBeenCalledTimes(4);
});
it("only enables JSON comparison for changed drafts and prevents editor blur on pointer down", async () => {
const compareJson = vi.fn();
const mounted = mountComponent(DataGridCellDetailPanel, {
detail: detail(),
panelIsBottom: true,
metadataCollapsed: false,
valueFillsHeight: true,
editing: true,
sideJsonView: false,
showCompactJson: true,
canCompactJson: true,
showCompareJson: true,
canCompareJson: false,
typeColorClass: () => "",
canDownloadBinaryValue: () => false,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => false,
importBinaryValue: vi.fn(),
openImagePreview: vi.fn(),
canCopySqlCondition: () => true,
onCompareJson: compareJson,
});
const disabledCompare = findOne(mounted.root, (node) => node.props.title === "grid.compareJson");
expect(disabledCompare.props.disabled).toBe(true);
dispatch(disabledCompare, "click");
expect(compareJson).not.toHaveBeenCalled();
await mounted.setProps({ canCompareJson: true });
const enabledCompare = findOne(mounted.root, (node) => node.props.title === "grid.compareJson");
expect(dispatch(enabledCompare, "mousedown").defaultPrevented).toBe(true);
dispatch(enabledCompare, "click");
expect(compareJson).toHaveBeenCalledOnce();
});
it("snapshots comparison values before opening and suppresses modal-induced blur commits", () => {
expect(dataGridSource).toContain("detailValueDiffSnapshot.value = snapshot;");
expect(dataGridSource).toContain("detailValueDiffOpen.value = true;");
expect(dataGridSource).toContain("if (!detailValueDiffOpen.value) commitValueEditorEdit();");
expect(dataGridSource).toContain(':disabled="!canCompareDetailJson" @mousedown.prevent @click="openDetailJsonCompare"');
expect(dataGridSource).toContain('v-model:open="detailValueDiffOpen" :snapshot="detailValueDiffSnapshot"');
});
});
describe("DataGridCopyColumnNamesDialog", () => {
+11
View File
@@ -2022,6 +2022,17 @@ export default {
editValue: "Edit Value",
formatJson: "Format JSON",
compactJson: "Compact JSON",
compareJson: "Compare",
valueDiffTitle: "{column} JSON difference",
valueDiffFormat: "Comparison format",
valueDiffRaw: "Raw",
valueDiffOriginal: "Original",
valueDiffCurrent: "Current",
copyOriginalValue: "Copy original value",
copyCurrentValue: "Copy current value",
cellValueCopied: "Cell value copied",
valueDiffInvalidJson: "Invalid JSON; showing the raw value: {message}",
valueDiffTooLarge: "Value exceeds {limit} characters; showing the raw value.",
formattedJsonEditWarning: "You are editing formatted JSON. Saving will write it as JSON, so the original formatting may change.",
setNull: "Set NULL",
restoreOriginalValue: "Restore Original",
+11
View File
@@ -1883,6 +1883,17 @@ export default withEnglishFallback({
editValue: "Editar valor",
formatJson: "Formatear JSON",
compactJson: "Compactar JSON",
compareJson: "Comparar",
valueDiffTitle: "Diferencia JSON de {column}",
valueDiffFormat: "Formato de comparación",
valueDiffRaw: "Original",
valueDiffOriginal: "Valor original",
valueDiffCurrent: "Valor actual",
copyOriginalValue: "Copiar valor original",
copyCurrentValue: "Copiar valor actual",
cellValueCopied: "Valor de celda copiado",
valueDiffInvalidJson: "JSON no válido; se muestra el valor original: {message}",
valueDiffTooLarge: "El valor supera los {limit} caracteres; se muestra el valor original.",
formattedJsonEditWarning: "Estás editando JSON formateado. Al guardar se escribirá como JSON, por lo que el formato original puede cambiar.",
setNull: "Establecer NULL",
restoreOriginalValue: "Restaurar original",
+11
View File
@@ -1881,6 +1881,17 @@ export default withEnglishFallback({
editValue: "Modifica Valore",
formatJson: "Formatta JSON",
compactJson: "Compatta JSON",
compareJson: "Confronta",
valueDiffTitle: "Differenza JSON di {column}",
valueDiffFormat: "Formato di confronto",
valueDiffRaw: "Originale",
valueDiffOriginal: "Valore originale",
valueDiffCurrent: "Valore corrente",
copyOriginalValue: "Copia valore originale",
copyCurrentValue: "Copia valore corrente",
cellValueCopied: "Valore della cella copiato",
valueDiffInvalidJson: "JSON non valido; viene mostrato il valore originale: {message}",
valueDiffTooLarge: "Il valore supera {limit} caratteri; viene mostrato il valore originale.",
formattedJsonEditWarning: "Stai modificando JSON formattato. Salvando verrà scritto come JSON, quindi la formattazione originale potrebbe cambiare.",
setNull: "Imposta NULL",
restoreOriginalValue: "Ripristina Originale",
+11
View File
@@ -1899,6 +1899,17 @@ export default withEnglishFallback({
editValue: "値を編集",
formatJson: "JSONをフォーマット",
compactJson: "JSON を圧縮",
compareJson: "比較",
valueDiffTitle: "{column} の JSON 差分",
valueDiffFormat: "比較形式",
valueDiffRaw: "生データ",
valueDiffOriginal: "元の値",
valueDiffCurrent: "現在の値",
copyOriginalValue: "元の値をコピー",
copyCurrentValue: "現在の値をコピー",
cellValueCopied: "セルの値をコピーしました",
valueDiffInvalidJson: "JSON が無効なため、生データを表示します: {message}",
valueDiffTooLarge: "値が {limit} 文字を超えているため、生データを表示します。",
formattedJsonEditWarning: "フォーマット済みJSONを編集中です。保存するとJSONとして書き込まれるため、元の書式が変わる場合があります。",
setNull: "NULLに設定",
restoreOriginalValue: "元の値に戻す",
+11
View File
@@ -1857,6 +1857,17 @@ export default withEnglishFallback({
editValue: "값 편집",
formatJson: "JSON 서식",
compactJson: "JSON 압축",
compareJson: "비교",
valueDiffTitle: "{column} JSON 차이",
valueDiffFormat: "비교 형식",
valueDiffRaw: "원본",
valueDiffOriginal: "원래 값",
valueDiffCurrent: "현재 값",
copyOriginalValue: "원래 값 복사",
copyCurrentValue: "현재 값 복사",
cellValueCopied: "셀 값을 복사했습니다",
valueDiffInvalidJson: "JSON이 올바르지 않아 원본 값을 표시합니다: {message}",
valueDiffTooLarge: "값이 {limit}자를 초과하여 원본 값을 표시합니다.",
formattedJsonEditWarning: "서식된 JSON을 편집하고 있습니다. 저장하면 JSON으로 기록되어 원래 서식이 변경될 수 있습니다.",
setNull: "NULL 설정",
restoreOriginalValue: "원래대로 복원",
+11
View File
@@ -1883,6 +1883,17 @@ export default withEnglishFallback({
editValue: "Editar Valor",
formatJson: "Formatar JSON",
compactJson: "Compactar JSON",
compareJson: "Comparar",
valueDiffTitle: "Diferença JSON de {column}",
valueDiffFormat: "Formato de comparação",
valueDiffRaw: "Original",
valueDiffOriginal: "Valor original",
valueDiffCurrent: "Valor atual",
copyOriginalValue: "Copiar valor original",
copyCurrentValue: "Copiar valor atual",
cellValueCopied: "Valor da célula copiado",
valueDiffInvalidJson: "JSON inválido; exibindo o valor original: {message}",
valueDiffTooLarge: "O valor excede {limit} caracteres; exibindo o valor original.",
formattedJsonEditWarning: "Você está editando JSON formatado. Ao salvar, ele será gravado como JSON, então a formatação original pode mudar.",
setNull: "Definir NULL",
restoreOriginalValue: "Restaurar Original",
+11
View File
@@ -1947,6 +1947,17 @@ export default withEnglishFallback({
editValue: "编辑值",
formatJson: "格式化 JSON",
compactJson: "压缩 JSON",
compareJson: "比较",
valueDiffTitle: "{column} JSON 差异",
valueDiffFormat: "对比格式",
valueDiffRaw: "原始",
valueDiffOriginal: "原值",
valueDiffCurrent: "当前值",
copyOriginalValue: "复制原值",
copyCurrentValue: "复制当前值",
cellValueCopied: "单元格值已复制",
valueDiffInvalidJson: "JSON 无效,已显示原始值:{message}",
valueDiffTooLarge: "值超过 {limit} 个字符,已显示原始值。",
formattedJsonEditWarning: "正在编辑格式化后的 JSON。保存后会以JSON格式写入,原始排版可能变化。",
setNull: "设为 NULL",
restoreOriginalValue: "恢复原值",
+11
View File
@@ -1887,6 +1887,17 @@ export default withEnglishFallback({
editValue: "編輯值",
formatJson: "格式化 JSON",
compactJson: "壓縮 JSON",
compareJson: "比較",
valueDiffTitle: "{column} JSON 差異",
valueDiffFormat: "比較格式",
valueDiffRaw: "原始",
valueDiffOriginal: "原值",
valueDiffCurrent: "目前值",
copyOriginalValue: "複製原值",
copyCurrentValue: "複製目前值",
cellValueCopied: "儲存格值已複製",
valueDiffInvalidJson: "JSON 無效,已顯示原始值:{message}",
valueDiffTooLarge: "值超過 {limit} 個字元,已顯示原始值。",
formattedJsonEditWarning: "正在編輯格式化後的 JSON。儲存後會以 JSON 格式寫入,原始排版可能改變。",
setNull: "設為 NULL",
restoreOriginalValue: "復原資料",
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { buildTextDiff } from "@/lib/common/textDiff";
import { createJsonValueDiffSnapshot, formatJsonValueDiffText, isJsonValueDiffAvailable, JSON_VALUE_DIFF_MAX_FORMAT_LENGTH, type JsonValueDiffCandidate } from "@/lib/dataGrid/jsonValueDiff";
function candidate(patch: Partial<JsonValueDiffCandidate> = {}): JsonValueDiffCandidate {
return {
columnName: "payload",
columnType: "JSON",
originalValue: '{"profile":{"name":"Ada","roles":["reader"]},"obsolete":true}',
currentValue: '{"profile":{"name":"Grace","roles":["reader","writer"]},"active":true}',
isEditable: true,
isEditing: true,
...patch,
};
}
describe("JSON value diff", () => {
it("only enables editable JSON sessions and snapshots changed draft text", () => {
const source = candidate({ currentValue: '{"profile":' });
expect(isJsonValueDiffAvailable(source)).toBe(true);
expect(isJsonValueDiffAvailable(candidate({ isEditable: false }))).toBe(false);
expect(isJsonValueDiffAvailable(candidate({ isEditing: false }))).toBe(false);
expect(isJsonValueDiffAvailable(candidate({ columnType: "TEXT", originalValue: "plain text" }))).toBe(false);
expect(isJsonValueDiffAvailable(candidate({ columnType: "TEXT" }))).toBe(true);
expect(createJsonValueDiffSnapshot(candidate({ currentValue: candidate().originalValue }))).toBeNull();
const snapshot = createJsonValueDiffSnapshot(source);
source.currentValue = '{"later":true}';
expect(snapshot).toEqual({ columnName: "payload", originalValue: candidate().originalValue, currentValue: '{"profile":' });
expect(Object.isFrozen(snapshot)).toBe(true);
});
it("formats nested object and array changes for aligned line and character diffs", () => {
const original = formatJsonValueDiffText(candidate().originalValue, "json");
const current = formatJsonValueDiffText(candidate().currentValue, "json");
const rows = buildTextDiff(original.text, current.text);
expect(original.error).toBeNull();
expect(current.error).toBeNull();
expect(rows.some((row) => row.kind === "modified" && row.beforeSegments.some((segment) => segment.changed))).toBe(true);
expect(rows.some((row) => row.kind === "added" && row.afterText.includes('"writer"'))).toBe(true);
expect(rows.some((row) => row.beforeText.includes('"obsolete"'))).toBe(true);
expect(rows.some((row) => row.afterText.includes('"active"'))).toBe(true);
});
it("normalizes whitespace in JSON mode while preserving it in raw mode", () => {
const original = '{"a":1,"items":[1,2]}';
const current = '{ "a": 1, "items": [1, 2] }';
const originalJson = formatJsonValueDiffText(original, "json");
const currentJson = formatJsonValueDiffText(current, "json");
expect(originalJson.text).toBe(currentJson.text);
expect(buildTextDiff(originalJson.text, currentJson.text).every((row) => row.kind === "equal")).toBe(true);
expect(buildTextDiff(formatJsonValueDiffText(original, "raw").text, formatJsonValueDiffText(current, "raw").text).some((row) => row.kind !== "equal")).toBe(true);
});
it("keeps an invalid current draft intact and reports the JSON fallback", () => {
const invalid = '{"items":[1,2]';
const result = formatJsonValueDiffText(invalid, "json");
expect(result.text).toBe(invalid);
expect(result.error).toMatchObject({ kind: "invalid-json" });
});
it("bounds large formatting and detailed character comparison", () => {
const original = `{"payload":"${"a".repeat(JSON_VALUE_DIFF_MAX_FORMAT_LENGTH)}"}`;
const current = `{"payload":"${"b".repeat(JSON_VALUE_DIFF_MAX_FORMAT_LENGTH)}"}`;
const formatted = formatJsonValueDiffText(current, "json");
const rows = buildTextDiff(original, current);
expect(formatted).toEqual({ text: current, error: { kind: "too-large", limit: JSON_VALUE_DIFF_MAX_FORMAT_LENGTH } });
expect(rows).toHaveLength(1);
expect(rows[0].kind).toBe("modified");
expect(rows[0].beforeSegments).toEqual([{ text: original, changed: true }]);
expect(rows[0].afterSegments).toEqual([{ text: current, changed: true }]);
});
});
+158
View File
@@ -0,0 +1,158 @@
import { diffChars, diffLines, type Change } from "diff";
export type TextDiffKind = "equal" | "added" | "removed" | "modified";
export interface TextDiffSegment {
text: string;
changed: boolean;
}
export interface TextDiffRow {
kind: TextDiffKind;
beforeLineNumber: number | null;
afterLineNumber: number | null;
beforeText: string;
afterText: string;
beforeSegments: TextDiffSegment[];
afterSegments: TextDiffSegment[];
}
export const TEXT_DIFF_MAX_DETAILED_CHARACTERS = 500_000;
export const TEXT_DIFF_MAX_DETAILED_LINES = 20_000;
export const TEXT_DIFF_MAX_CHARACTER_DIFF_CHARACTERS = 20_000;
function normalizeLineEndings(value: string): string {
return value.replace(/\r\n?/g, "\n");
}
function linesFromChange(change: Change): string[] {
if (!change.value) return [];
const lines = change.value.split("\n");
if (change.value.endsWith("\n")) lines.pop();
return lines;
}
function unchangedSegments(text: string): TextDiffSegment[] {
return text ? [{ text, changed: false }] : [];
}
function changedSegments(beforeText: string, afterText: string): { before: TextDiffSegment[]; after: TextDiffSegment[] } {
if (beforeText.length + afterText.length > TEXT_DIFF_MAX_CHARACTER_DIFF_CHARACTERS) {
return {
before: beforeText ? [{ text: beforeText, changed: true }] : [],
after: afterText ? [{ text: afterText, changed: true }] : [],
};
}
const changes = diffChars(beforeText, afterText);
return {
before: changes.filter((change) => !change.added).map((change) => ({ text: change.value, changed: !!change.removed })),
after: changes.filter((change) => !change.removed).map((change) => ({ text: change.value, changed: !!change.added })),
};
}
function appendEqualRows(rows: TextDiffRow[], lines: string[], beforeLine: { value: number }, afterLine: { value: number }) {
for (const line of lines) {
rows.push({
kind: "equal",
beforeLineNumber: beforeLine.value++,
afterLineNumber: afterLine.value++,
beforeText: line,
afterText: line,
beforeSegments: unchangedSegments(line),
afterSegments: unchangedSegments(line),
});
}
}
function appendChangedRows(rows: TextDiffRow[], removed: string[], added: string[], beforeLine: { value: number }, afterLine: { value: number }) {
const count = Math.max(removed.length, added.length);
for (let index = 0; index < count; index += 1) {
const beforeText = removed[index];
const afterText = added[index];
if (beforeText !== undefined && afterText !== undefined) {
const segments = changedSegments(beforeText, afterText);
rows.push({
kind: beforeText === afterText ? "equal" : "modified",
beforeLineNumber: beforeLine.value++,
afterLineNumber: afterLine.value++,
beforeText,
afterText,
beforeSegments: beforeText === afterText ? unchangedSegments(beforeText) : segments.before,
afterSegments: beforeText === afterText ? unchangedSegments(afterText) : segments.after,
});
} else if (beforeText !== undefined) {
rows.push({
kind: "removed",
beforeLineNumber: beforeLine.value++,
afterLineNumber: null,
beforeText,
afterText: "",
beforeSegments: unchangedSegments(beforeText),
afterSegments: [],
});
} else if (afterText !== undefined) {
rows.push({
kind: "added",
beforeLineNumber: null,
afterLineNumber: afterLine.value++,
beforeText: "",
afterText,
beforeSegments: [],
afterSegments: unchangedSegments(afterText),
});
}
}
}
function buildFallbackRows(before: string, after: string): TextDiffRow[] {
const beforeLines = before.split("\n");
const afterLines = after.split("\n");
const rows: TextDiffRow[] = [];
appendChangedRows(rows, beforeLines, afterLines, { value: 1 }, { value: 1 });
return rows;
}
function exceedsDetailedLineLimit(before: string, after: string): boolean {
let lines = 2;
for (const value of [before, after]) {
for (let index = 0; index < value.length; index += 1) {
if (value.charCodeAt(index) !== 10) continue;
lines += 1;
if (lines > TEXT_DIFF_MAX_DETAILED_LINES) return true;
}
}
return false;
}
export function buildTextDiff(beforeValue: string, afterValue: string): TextDiffRow[] {
const before = normalizeLineEndings(beforeValue);
const after = normalizeLineEndings(afterValue);
if (before.length + after.length > TEXT_DIFF_MAX_DETAILED_CHARACTERS || exceedsDetailedLineLimit(before, after)) return buildFallbackRows(before, after);
const changes = diffLines(before, after, { newlineIsToken: false });
const rows: TextDiffRow[] = [];
const beforeLine = { value: 1 };
const afterLine = { value: 1 };
let index = 0;
while (index < changes.length) {
const change = changes[index];
if (!change.added && !change.removed) {
appendEqualRows(rows, linesFromChange(change), beforeLine, afterLine);
index += 1;
continue;
}
const removed: string[] = [];
const added: string[] = [];
while (index < changes.length && (changes[index].added || changes[index].removed)) {
const changedLines = linesFromChange(changes[index]);
if (changes[index].removed) removed.push(...changedLines);
else added.push(...changedLines);
index += 1;
}
appendChangedRows(rows, removed, added, beforeLine, afterLine);
}
if (rows.length === 0) appendEqualRows(rows, [""], beforeLine, afterLine);
return rows;
}
@@ -0,0 +1,73 @@
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
import { isJsonColumnType, looksLikeJsonContainerText } from "@/lib/dataGrid/cellDetailPresentation";
export type JsonValueDiffMode = "json" | "raw";
export type JsonValueDiffFormatError = { kind: "invalid-json"; message: string } | { kind: "too-large"; limit: number };
export interface JsonValueDiffSnapshot {
columnName: string;
originalValue: string;
currentValue: string;
}
export interface JsonValueDiffFormatResult {
text: string;
error: JsonValueDiffFormatError | null;
}
export const JSON_VALUE_DIFF_MAX_FORMAT_LENGTH = 500_000;
export interface JsonValueDiffContext {
columnName: string;
columnType?: string;
originalValue: string;
isEditable: boolean;
isEditing: boolean;
}
export interface JsonValueDiffCandidate extends JsonValueDiffContext {
currentValue: string;
}
function isJsonSource(value: string): boolean {
if (!looksLikeJsonContainerText(value)) return false;
if (value.length > JSON_VALUE_DIFF_MAX_FORMAT_LENGTH) return true;
try {
formatJsonSource(value, 2);
return true;
} catch {
return false;
}
}
export function isJsonValueDiffAvailable(candidate: JsonValueDiffContext): boolean {
if (!candidate.isEditable || !candidate.isEditing) return false;
return isJsonColumnType(candidate.columnType) || isJsonSource(candidate.originalValue);
}
export function createJsonValueDiffSnapshot(candidate: JsonValueDiffCandidate): Readonly<JsonValueDiffSnapshot> | null {
if (!isJsonValueDiffAvailable(candidate) || candidate.originalValue === candidate.currentValue) return null;
return Object.freeze({
columnName: candidate.columnName,
originalValue: candidate.originalValue,
currentValue: candidate.currentValue,
});
}
export function formatJsonValueDiffText(value: string, mode: JsonValueDiffMode): JsonValueDiffFormatResult {
if (mode === "raw") return { text: value, error: null };
if (value.length > JSON_VALUE_DIFF_MAX_FORMAT_LENGTH) {
return { text: value, error: { kind: "too-large", limit: JSON_VALUE_DIFF_MAX_FORMAT_LENGTH } };
}
try {
return { text: formatJsonSource(value, 2), error: null };
} catch (error) {
return {
text: value,
error: {
kind: "invalid-json",
message: error instanceof Error ? error.message : String(error),
},
};
}
}
+5 -140
View File
@@ -1,5 +1,5 @@
import { diffChars, diffLines, type Change } from "diff";
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
import { buildTextDiff, type TextDiffKind, type TextDiffRow, type TextDiffSegment } from "@/lib/common/textDiff";
import { formatXmlSource } from "@/lib/common/xmlFormat";
import type { QueryResult } from "@/types/database";
import type { DoltDiffColumnKind, DoltRowChangeKind } from "@/lib/dolt/doltVersionControl";
@@ -9,7 +9,7 @@ export type DoltCellSide = "before" | "after";
export type DoltCellFormat = "raw" | "json" | "xml";
export type DoltCellFormatMode = "auto" | DoltCellFormat;
export type DoltCellValueState = "value" | "row-missing" | "column-missing";
export type DoltTextDiffKind = "equal" | "added" | "removed" | "modified";
export type DoltTextDiffKind = TextDiffKind;
export interface DoltDiffCellTarget {
rowIndex: number;
@@ -34,23 +34,8 @@ export interface DoltCellFormatResult {
error: string | null;
}
export interface DoltTextDiffSegment {
text: string;
changed: boolean;
}
export interface DoltTextDiffRow {
kind: DoltTextDiffKind;
beforeLineNumber: number | null;
afterLineNumber: number | null;
beforeText: string;
afterText: string;
beforeSegments: DoltTextDiffSegment[];
afterSegments: DoltTextDiffSegment[];
}
const MAX_DETAILED_LINE_DIFF_CHARACTERS = 500_000;
const MAX_CHARACTER_DIFF_CHARACTERS = 20_000;
export type DoltTextDiffSegment = TextDiffSegment;
export type DoltTextDiffRow = TextDiffRow;
export function doltCellValueState(target: DoltDiffCellTarget, side: DoltCellSide): DoltCellValueState {
if (side === "before") {
@@ -95,128 +80,8 @@ export function detectDoltCellFormat(values: readonly DoltCellDisplayValue[]): D
return "raw";
}
function normalizeLineEndings(value: string): string {
return value.replace(/\r\n?/g, "\n");
}
function linesFromChange(change: Change): string[] {
if (!change.value) return [];
const lines = change.value.split("\n");
if (change.value.endsWith("\n")) lines.pop();
return lines;
}
function unchangedSegments(text: string): DoltTextDiffSegment[] {
return text ? [{ text, changed: false }] : [];
}
function changedSegments(beforeText: string, afterText: string): { before: DoltTextDiffSegment[]; after: DoltTextDiffSegment[] } {
if (beforeText.length + afterText.length > MAX_CHARACTER_DIFF_CHARACTERS) {
return {
before: beforeText ? [{ text: beforeText, changed: true }] : [],
after: afterText ? [{ text: afterText, changed: true }] : [],
};
}
const changes = diffChars(beforeText, afterText);
return {
before: changes.filter((change) => !change.added).map((change) => ({ text: change.value, changed: !!change.removed })),
after: changes.filter((change) => !change.removed).map((change) => ({ text: change.value, changed: !!change.added })),
};
}
function appendEqualRows(rows: DoltTextDiffRow[], lines: string[], beforeLine: { value: number }, afterLine: { value: number }) {
for (const line of lines) {
rows.push({
kind: "equal",
beforeLineNumber: beforeLine.value++,
afterLineNumber: afterLine.value++,
beforeText: line,
afterText: line,
beforeSegments: unchangedSegments(line),
afterSegments: unchangedSegments(line),
});
}
}
function appendChangedRows(rows: DoltTextDiffRow[], removed: string[], added: string[], beforeLine: { value: number }, afterLine: { value: number }) {
const count = Math.max(removed.length, added.length);
for (let index = 0; index < count; index += 1) {
const beforeText = removed[index];
const afterText = added[index];
if (beforeText !== undefined && afterText !== undefined) {
const segments = changedSegments(beforeText, afterText);
rows.push({
kind: beforeText === afterText ? "equal" : "modified",
beforeLineNumber: beforeLine.value++,
afterLineNumber: afterLine.value++,
beforeText,
afterText,
beforeSegments: beforeText === afterText ? unchangedSegments(beforeText) : segments.before,
afterSegments: beforeText === afterText ? unchangedSegments(afterText) : segments.after,
});
} else if (beforeText !== undefined) {
rows.push({
kind: "removed",
beforeLineNumber: beforeLine.value++,
afterLineNumber: null,
beforeText,
afterText: "",
beforeSegments: unchangedSegments(beforeText),
afterSegments: [],
});
} else if (afterText !== undefined) {
rows.push({
kind: "added",
beforeLineNumber: null,
afterLineNumber: afterLine.value++,
beforeText: "",
afterText,
beforeSegments: [],
afterSegments: unchangedSegments(afterText),
});
}
}
}
function buildFallbackRows(before: string, after: string): DoltTextDiffRow[] {
const beforeLines = before.split("\n");
const afterLines = after.split("\n");
const rows: DoltTextDiffRow[] = [];
appendChangedRows(rows, beforeLines, afterLines, { value: 1 }, { value: 1 });
return rows;
}
export function buildDoltTextDiff(beforeValue: string, afterValue: string): DoltTextDiffRow[] {
const before = normalizeLineEndings(beforeValue);
const after = normalizeLineEndings(afterValue);
if (before.length + after.length > MAX_DETAILED_LINE_DIFF_CHARACTERS) return buildFallbackRows(before, after);
const changes = diffLines(before, after, { newlineIsToken: false });
const rows: DoltTextDiffRow[] = [];
const beforeLine = { value: 1 };
const afterLine = { value: 1 };
let index = 0;
while (index < changes.length) {
const change = changes[index];
if (!change.added && !change.removed) {
appendEqualRows(rows, linesFromChange(change), beforeLine, afterLine);
index += 1;
continue;
}
const removed: string[] = [];
const added: string[] = [];
while (index < changes.length && (changes[index].added || changes[index].removed)) {
const changedLines = linesFromChange(changes[index]);
if (changes[index].removed) removed.push(...changedLines);
else added.push(...changedLines);
index += 1;
}
appendChangedRows(rows, removed, added, beforeLine, afterLine);
}
if (rows.length === 0) appendEqualRows(rows, [""], beforeLine, afterLine);
return rows;
return buildTextDiff(beforeValue, afterValue);
}
export function doltCellCopyText(target: DoltDiffCellTarget, side: DoltCellSide): string | null {