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
|
||||
> </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
|
||||
> </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>
|
||||
+2
-2
@@ -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
|
||||
> </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
|
||||
> </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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "元の値に戻す",
|
||||
|
||||
@@ -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: "원래대로 복원",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "恢复原值",
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user