RSS Git Download  Clone
Raw Blame History 8kB 181 lines
<script setup lang="ts">
/**
 * DiffDialog — exact port of React DiffDialog.tsx
 * Inline / side-by-side diff view with collapsible context.
 */
import { ref, computed, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { diffLines, type Change } from 'diff'
import { useI18nStore } from '../stores/i18n'
import P3xrDialog from '../components/P3xrDialog.vue'

interface DiffBlock {
    type: 'added' | 'removed' | 'unchanged' | 'collapse'
    lines: string[]
    collapsedCount?: number
}

const CONTEXT_LINES = 3

function buildBlocks(changes: Change[]): DiffBlock[] {
    const blocks: DiffBlock[] = []
    for (const change of changes) {
        const lines = change.value.replace(/\n$/, '').split('\n')
        if (change.added) {
            blocks.push({ type: 'added', lines })
        } else if (change.removed) {
            blocks.push({ type: 'removed', lines })
        } else {
            if (lines.length <= CONTEXT_LINES * 2 + 1) {
                blocks.push({ type: 'unchanged', lines })
            } else {
                blocks.push({ type: 'unchanged', lines: lines.slice(0, CONTEXT_LINES) })
                const collapsed = lines.slice(CONTEXT_LINES, -CONTEXT_LINES)
                blocks.push({ type: 'collapse', lines: collapsed, collapsedCount: collapsed.length })
                blocks.push({ type: 'unchanged', lines: lines.slice(-CONTEXT_LINES) })
            }
        }
    }
    return blocks
}

const props = defineProps<{
    open: boolean
    keyName: string
    fieldName?: string
    oldValue: string
    newValue: string
}>()

const emit = defineEmits<{
    confirm: []
    cancel: []
}>()

const i18n = useI18nStore()
const strings = computed(() => i18n.strings)
const d = computed(() => strings.value?.diff || {} as any)
const { width } = useDisplay()
const isWide = computed(() => width.value >= 600)

const mode = ref<'inline' | 'side-by-side'>('inline')
const expanded = ref<Set<number>>(new Set())

watch(() => props.open, (v) => {
    if (v) {
        mode.value = 'inline'
        expanded.value = new Set()
    }
})

const changes = computed(() => diffLines(String(props.oldValue ?? ''), String(props.newValue ?? '')))
const blocks = computed(() => buildBlocks(changes.value))
const additions = computed(() => changes.value.filter(c => c.added).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0))
const deletions = computed(() => changes.value.filter(c => c.removed).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0))

function toggleExpand(i: number) {
    const s = new Set(expanded.value)
    s.has(i) ? s.delete(i) : s.add(i)
    expanded.value = s
}

function lineClass(type: string) {
    if (type === 'added') return 'p3xr-diff-added'
    if (type === 'removed') return 'p3xr-diff-removed'
    return 'p3xr-diff-unchanged'
}

const title = computed(() => {
    const prefix = d.value.reviewChanges
    const field = props.fieldName ? `${props.fieldName} @ ` : ''
    return `${prefix} — ${field}${props.keyName}`
})
</script>

<template>
    <P3xrDialog v-if="open" :open="true" :title="title" width="800px" :content-padding="false" @close="emit('cancel')">
        <template #headerActions>
            <v-btn-toggle v-model="mode" mandatory density="compact" class="p3xr-diff-mode-toggle" style="margin-right: 4px;">
                <v-btn value="inline" size="x-small">{{ d.inline }}</v-btn>
                <v-btn value="side-by-side" size="x-small">{{ d.sideBySide }}</v-btn>
            </v-btn-toggle>
            <span style="font-size: 12px; opacity: 0.8; margin-right: 4px; white-space: nowrap;">
                <span style="color: #81c784; font-weight: 700;">+{{ additions }}</span> {{ d.additions }},
                <span style="color: #ef9a9a; font-weight: 700;">-{{ deletions }}</span> {{ d.deletions }}
            </span>
        </template>

        <div class="p3xr-diff-body" :class="{ 'p3xr-diff-side': mode === 'side-by-side' }">
            <!-- Inline mode -->
            <template v-if="mode === 'inline'">
                <div>
                    <template v-for="(block, i) in blocks" :key="i">
                        <div v-if="block.type === 'collapse' && !expanded.has(i)" class="p3xr-diff-collapse" @click="toggleExpand(i)">
                            ... {{ block.collapsedCount }} {{ d.unchangedLines }} ...
                        </div>
                        <template v-else>
                            <div v-for="(line, j) in block.lines" :key="`${i}-${j}`" :class="lineClass(block.type)" class="p3xr-diff-line">
                                <span class="p3xr-diff-prefix">{{ block.type === 'added' ? '+' : block.type === 'removed' ? '-' : ' ' }}</span>{{ line }}
                            </div>
                        </template>
                    </template>
                </div>
            </template>

            <!-- Side-by-side mode -->
            <template v-else>
                <div style="border-right: 1px solid rgba(var(--v-theme-on-surface), 0.12); overflow: auto;">
                    <div style="padding: 4px 8px; font-weight: 500; border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12); position: sticky; top: 0; z-index: 1; background: rgb(var(--v-theme-surface));">
                        {{ d.before }}
                    </div>
                    <template v-for="(block, i) in blocks" :key="'l'+i">
                        <div v-if="block.type === 'collapse' && !expanded.has(i)" class="p3xr-diff-collapse" @click="toggleExpand(i)">
                            ... {{ block.collapsedCount }} {{ d.unchangedLines }} ...
                        </div>
                        <template v-else-if="block.type !== 'added'">
                            <div v-for="(line, j) in block.lines" :key="`l${i}-${j}`" :class="lineClass(block.type)" class="p3xr-diff-line">{{ line }}</div>
                        </template>
                    </template>
                </div>
                <div style="overflow: auto;">
                    <div style="padding: 4px 8px; font-weight: 500; border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12); position: sticky; top: 0; z-index: 1; background: rgb(var(--v-theme-surface));">
                        {{ d.after }}
                    </div>
                    <template v-for="(block, i) in blocks" :key="'r'+i">
                        <div v-if="block.type === 'collapse' && !expanded.has(i)" class="p3xr-diff-collapse" @click="toggleExpand(i)">
                            ... {{ block.collapsedCount }} {{ d.unchangedLines }} ...
                        </div>
                        <template v-else-if="block.type !== 'removed'">
                            <div v-for="(line, j) in block.lines" :key="`r${i}-${j}`" :class="lineClass(block.type)" class="p3xr-diff-line">{{ line }}</div>
                        </template>
                    </template>
                </div>
            </template>
        </div>

        <template #actions>
            <v-btn variant="flat" color="warning" @click="emit('cancel')">
                <v-icon :class="{ 'mr-1': isWide }">mdi-close-circle</v-icon>
                <span v-if="isWide">{{ strings?.intention?.cancel }}</span>
            </v-btn>
            <v-btn variant="flat" color="primary" @click="emit('confirm')">
                <v-icon :class="{ 'mr-1': isWide }">mdi-check</v-icon>
                <span v-if="isWide">{{ strings?.intention?.save }}</span>
            </v-btn>
        </template>
    </P3xrDialog>
</template>

<style>
.p3xr-diff-body { min-height: 200px; max-height: 60vh; overflow: auto; }
.p3xr-diff-side { display: grid; grid-template-columns: 1fr 1fr; }
.p3xr-diff-line { padding: 1px 8px; white-space: pre-wrap; word-break: break-all; font-family: 'Roboto Mono', monospace; font-size: 13px; }
.p3xr-diff-added { background-color: rgba(76,175,80,0.12); }
.p3xr-diff-removed { background-color: rgba(244,67,54,0.12); }
.p3xr-diff-unchanged { opacity: 0.6; }
.p3xr-diff-collapse { padding: 4px 8px; opacity: 0.4; font-style: italic; cursor: pointer; font-family: 'Roboto Mono', monospace; font-size: 13px; }
.p3xr-diff-collapse:hover { opacity: 0.7; }
.p3xr-diff-prefix { display: inline-block; width: 16px; font-weight: 700; user-select: none; }
.p3xr-diff-mode-toggle .v-btn { padding: 2px 12px !important; font-size: 12px !important; border-radius: 4px !important; text-transform: none !important; }
</style>