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", () => {